diff --git a/.env.example b/.env.example index 5bb505d..fe5e5f6 100644 --- a/.env.example +++ b/.env.example @@ -16,15 +16,21 @@ MEMBY_PORT=32768 # INFO is recommended. DEBUG also logs successful health, status and artwork requests. MEMBY_LOG_LEVEL=INFO +MEMBY_LOG_BUFFER_CAPACITY=5000 +MEMBY_GOMEMLIMIT=384MiB +MEMBY_SERVER_MEMORY_LIMIT=512m -# Used for "today" boundaries and human-readable Sonarr air/download times. +# Used for local Sonarr air times and Radarr digital-release day boundaries. MEMBY_TIMEZONE=Pacific/Auckland # How long a cached home payload stays warm. MEMBY_HOME_TTL=60s +MEMBY_RECOMMEND_TTL=24h +# Optional JSON overlay on the explainable weighted-ranking defaults. +# MEMBY_RECOMMENDATION_WEIGHTS={"ExplorationRate":0.08,"MinimumEvidence":2,"ImpressionPenalty":0.12} +MEMBY_RECOMMENDATION_WEIGHTS= # Maximum number of distinct Memby TVs one Emby user may keep signed in. -MEMBY_MAX_CLIENTS_PER_USER=1 # Admin interface at https://mserver.sublogue.com/admin/ — library imports, the # maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with @@ -34,7 +40,7 @@ MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd # Public gateway address and a dedicated token used only by the Gitea release workflow. # Generate the token with: openssl rand -hex 32 MEMBY_PUBLIC_URL=https://mserver.sublogue.com -MEMBY_RELEASE_PUBLISH_TOKEN= +MEMBY_RELEASE_PUBLISH_TOKEN=4b0a891382f056d6ab2ddb43ad08fbbadda0a5a7db9371c7d90fd57b38d083d2 # Library import. Hourly incremental keeps up with episodes added through the day. MEMBY_SYNC_INTERVAL=1h @@ -55,6 +61,24 @@ MEMBY_SONARR_TTL=5m # 0 turns the banners off and leaves the airing-today row alone. MEMBY_SONARR_ALERT_WINDOW=3h +# Optional Radarr calendar integration. Upcoming movies are selected strictly from +# Radarr's digital release date; theatrical and physical dates are ignored. +MEMBY_RADARR_URL=http://10.0.0.2:7878 +MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5 +MEMBY_RADARR_TTL=5m +# Shared secret for Radarr's "On Import" webhook, which announces a newly added film on +# every TV that is awake. In Radarr: Settings > Connect > + > Webhook, On Import only, +# URL https:///hooks/radarr?token=. Empty makes the hook 404. +MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758 +# How long an imported film keeps being announced, so a TV switched on shortly after the +# import still hears about it. 0 turns the banners off. +MEMBY_RADARR_ALERT_WINDOW=3h + +# How often the gateway checks that Emby is answering. A run of failures raises the +# "server not responding" banner on every TV, including mid-playback, and recovery +# raises the matching "back online" one. 0 turns the probe and both banners off. +MEMBY_EMBY_HEALTH_INTERVAL=60s + # Optional Tracearr-powered For You signals. Create a read-only public API key in # Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers. MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/ @@ -62,5 +86,6 @@ MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0 MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b MEMBY_TRACEARR_SYNC_INTERVAL=5m MEMBY_TRACEARR_FULL_INTERVAL=24h -MEMBY_FOR_YOU_MIN_REBUILD_AGE=10m -MEMBY_FOR_YOU_REFRESH_INTERVAL=30m +MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h +MEMBY_FOR_YOU_REFRESH_INTERVAL=24h +MEMBY_FOR_YOU_REBUILD_HOUR=4 diff --git a/.gitignore b/.gitignore index ec997ae..048a035 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ local.properties # Holds the Postgres password, admin token and Emby address. .env /server/bin/ +/server/.tmp-go-cache/ # Release artefacts and signing material. The keystore must never be committed: # whoever holds it can publish an update that installs over Memby. diff --git a/CLAUDE.md b/CLAUDE.md index a840ea5..fbd6978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,9 +203,38 @@ the rail) because otherwise D-pad focus has nowhere to go once the rows are gone `MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar across the top of the screen, broadcast-notice style (it spans the navigation rail too). -The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed -but which Emby has not imported yet ("aired, coming soon"). It reads the *cached* -airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve: +Alerts come from two shapes of producer. **Derived**: `api/alerts.go` announces an episode +whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming +soon"), recomputed per poll from the *cached* airing-today calendar, so polling clients +never cost a Sonarr request. **Events**, which are published into one shared Redis list +(`publishAlert` / `publishedAlerts`, also in `alerts.go`) and served from it until their +window closes — a list rather than a push because the gateway holds no connection to a +television, and a window is what lets a set that was off or in the screensaver at the time +still hear the news. Three publishers today: + +- `api/radarr_alerts.go` — a film Radarr just imported. `POST /hooks/radarr` is the "On + Import" webhook and the one thing that pushes *into* the gateway, guarded by + `MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted + outside both the auth middleware and the maintenance gate, because an event dropped + during maintenance is lost rather than delayed. A quality upgrade is deliberately + silent: the film was already there. +- `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in + `main.go` — "24 titles added or updated". Only a run that *changed* something is + announced; the import is scheduled, most passes find nothing, and an hourly "no news" + banner would train viewers to ignore the real ones. +- `WatchEmbyReachability`, same file — "Emby has stopped communicating" and the matching + "back online". Only transitions are announced, and only after + `embyFailureThreshold` consecutive failures, because one timeout is a hiccup and + repeating an outage every minute would bury everything else. This is the alert that + earns the banner its place over playback: video direct-plays from Emby, so when Emby + stops answering the film stalls with no explanation, and the gateway is still up to + say why. `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe and both banners off. + +`mergeAlerts` interleaves derived and published alerts newest-first and caps at +`maxAlerts`, which is why every alert carries a timestamp. Alerts also carry their own +`label` (the banner's eyebrow — "JUST AIRED", "NEW MOVIE ADDED", "SERVER NOT RESPONDING"), +so a new kind of news reads correctly on an app that predates it; a client that receives +none falls back to the episode wording. Things to preserve: the server has no idea which TVs saw what, so the client dedupes by id against `SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no @@ -219,7 +248,21 @@ times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring c down — take the duration from that constant, or the ring and the timer drift apart), because stealing D-pad focus mid-browse is worse than a missed notice; and alerts are suppressed under maintenance and under a mandatory update, which own the -screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row. +screen. + +**The bar appears over playback too**, not only over the launcher: `PlayerActivity` +mounts the same composable in a `ComposeView` (`player_service_alerts` in +`activity_player.xml`, declared before the loading and error overlays so those cover it). +`alertsSuppressed` starts *true* and is cleared only by `hidePlaybackLoading`, which is +reached once the preroll is over and the first frame is up — an alert composed behind an +overlay would be marked seen by a viewer who never saw it, which is the same failure +`alertShown` exists to prevent. Because it now covers somebody's film, the bar is +deliberately small (76dp), near-black, and eases in over ~680ms rather than snapping +down. It shows the **Emby mark**, not item artwork: a library refresh and an outage have +no artwork, and one constant mark reads as "your server is talking" where a poster made +every alert look like a different feature. The wire still carries `itemId`/`imageTag`; +the client just does not render them. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row, +and `MEMBY_RADARR_ALERT_WINDOW=0` does the same for movie imports. **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. @@ -254,6 +297,15 @@ step, no CDN — a strict no-dependency page is the whole point). It polls `/admin/api/status` every 5s. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin` route 404s. +**Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a +pure function turning the learned weights into the phrases a detail page shows. It is kept +apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides +*wording* and must never invent an affinity, which is what `reasonFloor` and the +unit tests enforce. `PersonWeights` exists only for this: casting is a good reason to tell +someone about a title and a poor reason to rank by it. `api/related.go` serves it beside +Emby's similarity list at `GET /v1/items/{id}/related`, cached per user and item because +building the profile costs the same Emby fan-out the home rows pay for. + **Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring (recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does the Emby fan-out. Both are unit-tested without a network — `engine.go` takes a narrow @@ -279,15 +331,58 @@ Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks `resumePositionMs`). **Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server, -token, userId, plus that profile's cached home JSON) *and* mirrors the active profile into -the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep -both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that -predate the list. `deviceId` is intentionally preserved across `clearSession()`. +token, userId) *and* mirrors the active profile into the flat top-level keys the rest of +the app reads. `switchProfile`/`saveSession` must keep both in sync; `legacyProfile()` +synthesises a profile from the flat keys for installs that predate the list. `deviceId` is +intentionally preserved across `clearSession()`. + +The profiles blob deliberately does **not** carry cached home JSON. Preferences DataStore +rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache +per profile meant every settings toggle rewrote all of them. `writeProfiles` is the single +writer and strips the field out into a per-profile `home_cache::@` key, +which doubles as the migration for installs that still embed one — `applyProfile` reads +the dedicated key and falls back to the embedded copy. Add a profiles write and it must go +through `writeProfiles`. + +That per-profile key is also the **read** path (`activeHomeCache`), and the only place the +cache is written. The flat `home_cache` key remains only for a store with no active profile +to key against, and for installs written before the split. It briefly held a second copy of +every cache alongside the per-profile one, which put the largest value in the store into the +file twice — on a format that rewrites and fsyncs the whole file per edit, and on a value +rewritten by every home refresh. **Home startup path.** `HomeCache` (last successful home response) is persisted per profile and used as the initial `HomeUiState`, so the launcher renders rows before the network returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows. +Three things protect that "before the network returns" promise, and all three are easy to +undo by accident: + +- **Nothing on the signed-in path may block on a request.** `AppRoot` used to hold the + launcher on the loading screen until `getRecommendationOnboarding()` answered, which on a + slow connection meant the cached rows could not be drawn until the connect timeout + expired. Onboarding completion is now persisted (`Settings.hasCompletedOnboarding`, + `markOnboardingCompleted`) and consulted first; the gateway is still asked in the + background and stays authoritative for anyone not yet recorded. For a profile with no + record yet the ask is still on the critical path, so it is bounded by + `ONBOARDING_CHECK_TIMEOUT_MS` and fails open to the launcher — and only a verdict the + gateway actually returned is persisted, or a single slow response would retire the rating + screen for someone who has never seen it. The `onboardingToken` guard that stops repeat + asks must always be paired with a null check on the verdict itself: on its own it can + match a restarted effect's own token and strand the launcher on the loading screen + permanently. +- **The settings flow must never be able to terminate.** It is the gate everything waits + behind, and a `shareIn`ed flow that completes exceptionally never emits again — so a + single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence the + `ReplaceFileCorruptionHandler` on the DataStore and the `catch` before `shareIn`. Do not + remove either: this store is rewritten on every home refresh, so a process killed + mid-write is an ordinary event on a TV. +- **The cache is decoded off the main thread.** `SettingsStore.primeHomeCache` parses it on + the store's IO scope as the settings flow emits, and `homeCache()` returns that memoized + copy — `HomeViewModel`'s constructor runs during composition, so decoding there parsed + the whole blob on the main thread at exactly the wrong moment. +- **`setHomeCache` skips unchanged writes** (`lastPersistedHomeCache`). It runs on every + refresh *and* every playback stop, and most passes find nothing new. **Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and `ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so @@ -324,9 +419,32 @@ inside the running player instead of relaunching the activity, so `itemId`/`play stopped; and a movie simply resolves to null, which is why nothing special-cases item type. **Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to -tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently -targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are -debug-influenced. +tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting +the release variants the `androidx.baselineprofile` plugin generates, so its numbers are +real rather than debug-influenced. `HomeBenchmark` deliberately measures cold start twice — +`CompilationMode.None()` and `Partial()` — because the only way to know the baseline +profile is earning its keep is to see both numbers. + +**Release builds are minified.** `isMinifyEnabled`/`isShrinkResources` are on, which takes +the APK from ~14.9MB to ~3.1MB and the dex from 49MB across four files to 4.9MB in one — +no multidex, which matters because `minSdk` is 23. `proguard-rules.pro` is what keeps that +safe: it matches `@kotlinx.serialization.Serializable` on the *annotation* rather than +listing packages, because the previous rules named `com.mattcohen.embyscreensaver.data.model` +and had silently matched nothing since v0.1.53. Lint still has `abortOnError = false`, so +R8 warnings do not fail the build — check the task output after changing dependencies. + +**Baseline profile.** `androidx.profileinstaller` plus a profile generated by +`benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with +`.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under +`app/src/release/generated/baselineProfiles`, so an ordinary `assembleRelease` needs no +device. A stale profile is not harmful, only progressively less useful. + +**One HTTP stack.** `data/remote/HttpStack.kt` owns the single `OkHttpClient` that the +Emby API, the gateway API and Coil's artwork loader all derive from with `newBuilder()`, +so they share one connection pool and dispatcher. This matters most for artwork: in +gateway mode the images are proxied by the same HTTPS host that serves `/v1/home`, so a +separate client would repeat the TLS handshake for every poster. Don't construct a bare +`OkHttpClient.Builder()` — derive from `HttpStack.base`. ## UI conventions @@ -346,6 +464,125 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar. rule applies to collecting flows — collect in the smallest composable that needs the value, not at the top of `MainActivity`, or every emission recomposes the launcher. +**Where a composable is too large to split, narrow the state instead.** `HomeScreen` is +the case: it cannot reasonably collect `HomeUiState` in one place, because reading the +whole object there meant an arriving update verdict, a slow-connection banner or any one +of the four section loads invalidated the launcher *and* rebuilt every row with it. So +`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and +their loading flags), `status` (connection health) and `appUpdate` — and the screen +subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather +than introducing a separate type, which is what lets `homeRowsFor` keep taking a +`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it. + +**Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read: +`MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/ +`MembyQuietText`, `MembyScore` for the community rating, three corner radii +(`MembyChipCorner` 8dp, `MembyCardCorner` 10dp, `MembyPanelCorner` 14dp), and the two +separators — `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a +list. `HomeComponents`' `EmbyGreen`/`MutedText`/`QuietText` and `DetailPageComponents`' +`Detail*` colours are aliases of these; they had drifted into four near-blacks, four greens +and two secondary greys, which is visible the moment a detail page opens from a row. A new +colour or radius belongs in the token file, or is a considered exception — not a fifth +value. + +**One button language.** `ui/MembyButtons.kt` — `MembyPlayButton` (focusable), +`MembyPlayChip` (the same surface as decoration inside an already-focusable parent, for the +home hero) and `MembyChoiceChip`. There were three: this one, a hand-rolled copy in the hero +with the same look and different metrics, and raw `androidx.tv.material3.Button`s with +glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours +nothing around them uses. + +**One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`, +`heroFacts`, `ratingLabel`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the +card metadata call them rather than carrying private copies. That is why the hero and the +card directly beneath it agree on "2h 4m", and why `mediaBadges` and the spec row's `(4K)` +suffix fire at the same width. + +**Home rows all lead with the same header**: `HomeRowHeaderIcon` + `HomeRowHeaderIconGap`, +`HomeRowHeaderSpacing`. A header that skips the icon chip starts its title 38dp left of +every other row, and the launcher's row titles are read as one column. + +**The home hero** (`ui/HomeMovieHero.kt`) is a featured card plus three minis, each a +`HomeHeroPick` — the item *and the row it was drawn from*. The caption used to be the card's +slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interleaves sources +and falls back to every movie in the response, so it routinely lied. The featured card is a +fixed height with its content centred, so anything over budget is lost top and bottom and +Play, being last, goes first: a wrapped title stands the synopsis down rather than the +button. + +**Detail pages** are one editorial layout shared by movies and series: `DetailPageScaffold` +in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`. It +is a full-bleed cinematic hero — backdrop under two scrims, logo or title, `heroFacts` line +(year · length · certificate) with the score and format badges trailing it, genres, three +lines of synopsis, one recommendation reason, then Play and the circular secondary actions — +with an uppercase tab strip on a hairline rule anchored under it and the tab's content +beginning below the fold. `MediaDetailContent` is the movie page, `SeriesDetailContent` the +series one; they differ only in which tabs they offer. The tabs are Overview, Episodes +(series only), More Like This and Cast & Details. + +- **The page is a `LazyColumn` of exactly three items** — hero, tabs, content — and the hero + owns the opening frame: while focus is in it the list is pinned to offset 0 (see + `detailHeroScrollTarget` and the `snapshotFlow` beside it), because LazyColumn's own focus + relocation would otherwise leave Play visible with the title scrolled off the top. Moving + to the strip or the content releases the pin. +- **A pane never scrolls.** Every section is a tab and every tab fits its slot, so adding + content means adding a tab: a page that scrolls *and* has tabs gives the D-pad two + meanings for Down. The slot is `detailPaneHeight(viewportHeight)`, derived from the screen + rather than fixed — it was a hard 250dp, and everything `technicalSpecs()` produces fell + off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane + needs more than the budget, cut rows; do not add a scroller. +- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge, + where overscan was cutting the selection underline in half, and leaves the top of the pane + showing beneath it. That peek and the chevron at the end of the strip are the only things + on screen saying that Down reveals anything. +- **Focus is selection** in the tab strip. A remote has no hover, so a strip that highlights + one tab while a different one stays open would need a second press to mean anything and + would show content that contradicts the highlight. +- **The strip is decided by what the item is, never by what has loaded.** + `detailTabs(isSeries)` returns a fixed list, and a section with nothing in it yet says so + in its own pane. It used to offer only the sections that already had content, which meant + a movie opened with one tab and grew two more when its detail record landed, moving the + strip under the viewer's thumb. `detailTab(key, available)` still resolves a remembered + key, but now only has to catch a key carried over from the other kind of item. +- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps + the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview + and the Cast & Details pane is attached to two live nodes, and a Down press landing in that + window can focus the pane that is disappearing. +- **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which + band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store + outside the composition, because closing a detail overlay destroys `rememberSaveable` with + it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live) + and then restores the band, once, only if that band has something placed to land on. + Deliberately not persisted: a TV switched on the next morning should open a show where the + *show* is up to. +- Every `focusProperties { up/down/left/right = … }` target must be attached **on the + current frame**. Season chips and episode cards do not exist while the episode request is + in flight, on a one-season show, or on any tab but Episodes — pointing at their + `FocusRequester` anyway throws the moment the viewer presses that direction. + `SeriesDetailContent` resolves each target to `FocusRequester.Default` when its + destination is off screen; keep that. +- The backdrop is held under two gradients before any text is drawn. The reference is flat + black and that flatness is most of why it reads as modern; the artwork is there for tone, + not as a picture. +- A tab item is `Modifier.width(IntrinsicSize.Max)`. Without it the underline's + `fillMaxWidth` claims the whole strip and pushes every later tab off the screen. +- The hero honours `Settings.showTitleLogo` and `useTextTitleForLogo` (`ui/TitleLogo.kt`, + shared with the screensaver): transparent Emby logos are commonly black, and a black title + treatment on this scrim is an invisible heading. +- **Why you might enjoy it** is the single accent line above the actions, from + `GET /v1/items/{id}/related`. It comes from the same profile the home rows are built from + (`recommend.Why`), so the page can only claim a taste the engine actually learned; a cold + profile falls back to catalogue facts. Never focusable. +- **More Like This** is the same response's `items`, as its own tab. Selecting one opens + *its* detail page, and `MainActivity.detailsTrail` walks Back home one page at a time. On + the direct path there is no engine, so `EmbyRepository.getRelated` returns Emby's + `Items/{id}/Similar` with no reasons at all — both halves are allowed to be empty and the + page still opens. +- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer) and + delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be + screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by clicking it and + also renders each pane on its own at `detailPaneHeight`) without a server. + **Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV, landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather than a bare `@Preview`, which defaults to a phone and misrepresents every layout here. diff --git a/DESIGN_FIXES.md b/DESIGN_FIXES.md new file mode 100644 index 0000000..9b98742 --- /dev/null +++ b/DESIGN_FIXES.md @@ -0,0 +1,216 @@ +# Design fixes — home screen and detail pages + +Audit of 2026-08-01. Findings only; no product code was changed. The three items under +"Confirmed layout bugs" were reproduced by rendering the real composables at TV 1080p +(`w960dp-h540dp-television-xhdpi`) through the existing Roborazzi harness — everything +else is read from the source. + +**All of it is implemented.** The findings below are left as written — they are the +diagnosis, and each one says why the fix is shaped the way it is. What was done: + +| # | Fixed in | +|---|----------| +| 1 | `HomeMovieHero.kt` — column padding 28→22dp, and a wrapped title stands the synopsis down (`onTextLayout` line count) so Play is never what gets cut. Captured as `df_home-movie-hero-long-title.png`. | +| 2 | `DetailPageComponents.detailPaneHeight()` — the slot is derived from the viewport (250–420dp) instead of a hard 250dp. Every technical spec now renders; `df_detail-pane-cast-details.png`. Studio, which both columns claimed, is dropped from the technical column. | +| 3 | `DetailFoldPeek` (34dp) holds the tab strip off the bottom edge. | +| 4 | The hero's private `heroFacts`/runtime formatter is gone; it calls `detail/DetailFacts.kt`. `HomeComponents.formatTvRuntime` too — one formatter left in the app. | +| 5 | `MembyScore` token, and every rating goes through `ratingLabel` (`Locale.US`). The home metadata panel renders the score as its own run of text so it can carry the same gold. | +| 6 | "No favourite shows yet" / "Mark a series as a favourite…". | +| 7 | `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. | +| 8 | `ui/theme/DesignTokens.kt`; `HomeComponents` and `DetailPageComponents` colours are aliases of it, and `Theme.kt` uses the same near-blacks. | +| 9 | Same — the detail page picked up the raised TV neutrals. | +| 10 | Three radii: `MembyChipCorner` 8, `MembyCardCorner` 10, `MembyPanelCorner` 14. | +| 11 | `ui/MembyButtons.kt` — `MembyPlayButton`, `MembyPlayChip`, `MembyChoiceChip`. The hero chip, the detail Play button, the metadata panel's "▶ Resume" and the For You time budget all use them. | +| 12 | `DetailHero` honours `Settings.showTitleLogo` (new `EmbyRepository.showTitleLogo`) and shares `useTextTitleForLogo` with the screensaver (`ui/TitleLogo.kt`). | +| 13 | One `UHD_MIN_WIDTH` (3800) for the badge and the `(4K)` suffix. Unit-tested. | +| 14 | `mediaBadges` reads `dynamicRangeLabel`, so HDR10+ stays HDR10+. Unit-tested. | +| 15 | One `FocusRequester` per pane in both overlays; none is attached to two live nodes. | +| 16 | A series passes `mediaBadges(item)`, and `DetailFactRow` takes 4 badges so the airing badge is not squeezed out. | +| 17 | `HomeRowHeaderIcon` / `HomeRowHeaderIconGap` / `HomeRowHeaderSpacing`, used by `MediaRow`, `MyShowsStrip` and `RecentSearchesRow` — which also gained the vertical padding its focus-scaled chips needed. | +| 18 | `HomeHeroPick` carries the row a title was drawn from; the caption is no longer the card's slot. Unit-tested. | +| 19 | Deleted (≈260 lines: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData`, `HomeRowSkeleton` and the two runtime formatters only they used). | +| 20 | The peek under the strip plus a chevron at its end. | +| Docs | `CLAUDE.md`'s detail-page section rewritten to describe this code, with the token, button and header conventions above it. | + +Screenshots of the result are `app/build/screenshots/df_*.png` +(`.\gradlew.bat :app:testDebugUnitTest --tests "*ScreenshotTest"`). + +--- + +## Confirmed layout bugs + +These clip real content on a real TV. Fix these first. + +### 1. The featured home hero drops its Play button when the title wraps to two lines + +`ui/HomeMovieHero.kt:171-236`, `ui/MainActivity.kt:137` (`homeHeaderHeight`) + +The card's content column measures ~229dp with a one-line title and ~261dp with two. +`homeHeaderHeight(540dp, showHero = true)` yields 248dp, minus the hero row's 16dp top and +10dp bottom padding, so the card gets **222dp** — and `FocusScaleContainer` clips it to a +14dp rounded rect. Rendered with a two-line title, the kicker, title, fact line and +synopsis draw and the green Play chip is **gone entirely**. A one-line title is already +7dp over budget; it only survives because the part cut off is the chip's shadow. + +Fix direction: the column is `align(Alignment.CenterStart)` inside a fixed-height box, so +overflow is split top and bottom and the button is always the first thing lost. Either +give the hero a height derived from its content, cap the title at one line, or drop the +synopsis when the title wraps. + +### 2. The Cast & Details tab silently discards every technical spec + +`ui/DetailPageComponents.kt:258-278` (the 250dp pane), `:563-580` (`DetailFocusablePane`), +`:613-643` (`DetailCastAndDetailsPane`) + +The tab content slot is a hard `.height(250.dp)` and `DetailFocusablePane` applies +`.clip(RoundedCornerShape(10.dp))`. `releaseAndTechnical` builds Released / Certificate / +Runtime and then `addAll(specs)` — Video, Codec, Audio, Subtitles, Studio. Rendered at the +real slot geometry, only the first three rows survive; the entire output of +`technicalSpecs()` is clipped below the fold of a pane that cannot scroll. That is the +whole reason the tab exists. + +`DetailOverviewPane` shares the ceiling: a five-line synopsis plus the series "Up next" +supporting line pushes its credit rows past the same boundary. + +Fix direction: the pane needs a height budget that accounts for its worst case, or the +two-column meta block needs to page/scroll. Note the pane deliberately does not scroll +(one screen per tab), so the honest fix is probably fewer rows per column, not a scroller. + +### 3. The detail tab strip sits flush against the bottom screen edge + +`ui/DetailPageComponents.kt:209` (`heroHeight = maxHeight - DetailTabHeight`), `:494-550` + +The selection underline is cut in half at y=1080 in both `detail-movie-more-like-this.png` +and `detail-series-cast-details.png`. On a TV with overscan the underline and part of the +labels are off-screen. This is the only element in the app with zero safe-area inset — +gutters are 36-58dp and the home clock keeps 18dp. + +--- + +## Copy and formatting + +### 4. The home hero formats runtime differently from everywhere else + +`ui/HomeMovieHero.kt:351` is a private `heroFacts` shadowing `ui/detail/DetailFacts.kt:62`. +Different field order (year · certificate · runtime vs year · runtime · certificate) and +`"${it}m"` instead of `formatRuntime`. The checked-in `home-movie-hero.png` shows +**"2026 • M • 124m"** in the hero and **"2026 • 2h 4m"** on the card directly beneath it. + +There are three runtime formatters in the app: `DetailFacts.formatRuntime`, +`HomeComponents.formatTvRuntime` (private, identical) and this one. + +### 5. The community score changes colour and locale by screen + +Gold `0xFFF5C518` on detail (`DetailPageComponents.kt:484`), grey `MutedText` on home +(`HomeComponents.kt:1025`). `HomeMovieHero.kt:355` uses `"★ %.1f".format(it)` with the +default locale while every other rating goes through `Locale.US` — a comma decimal in a +non-US locale. + +### 6. American spelling in two user-facing strings + +`ui/HomeComponents.kt:1549` "No **favorite** shows yet" and `:1555` "Mark a series as a +**favorite** and it'll be waiting here." The rail says Favourites, the quick menu says +"Add to favourites", the detail hero action says "Add to Favourites". + +### 7. Four separator styles for the same kind of fact list + +`" • "` (home metadata), `" • "` (detail fact row, schedule metadata, home hero), +`" · "` (home genres), `", "` (credit rows). + +--- + +## Design-token drift + +### 8. Four near-blacks + +Theme `background 0xFF0B0E11` and `surface 0xFF101418` (`ui/theme/Theme.kt`, effectively +unused), home `0xFF090B0D`, detail `0xFF080A0C`. The accent green is duplicated four ways: +`EmbyGreen`, `DetailAccent`, and the hero's `0xFF69C762` / `0xFF7BD574`. + +### 9. Secondary-text contrast diverged between the two screens + +`ui/HomeComponents.kt:129-132` carries a comment about raising the neutrals for TV distance +(`MutedText 0xFFD0D6DB`, `QuietText 0xFFAEB7BF`). The detail page still uses the pre-fix +values (`DetailMutedText 0xFFB6BDC3`, `DetailQuietText 0xFF8C959D`). The two sit side by +side the moment a detail page is opened from a row. + +### 10. Corner radii are ad hoc + +9dp home cards, 8dp related posters, 8/10dp cast cards, 14dp featured hero, 11dp mini hero, +12dp overlays, 7dp chips, 999dp search chips. + +### 11. Three button languages + +The hand-rolled `DetailPlayButton` (23/12dp padding, 16sp), the hero's hand-rolled play chip +(12/7dp, 13sp — same look, different metrics), and raw `androidx.tv.material3.Button` with +glyph text in `MediaMetadataPanel` ("▶ Resume") and `ForYouTimeBudget` ("✓ 30 min"), which +picks up theme colours nothing else in the app uses. + +--- + +## Logic and behaviour + +### 12. `showTitleLogo` is ignored by the detail pages + +The Settings copy promises "shows each title's logo artwork from Emby instead of plain +text", but only `ui/screensaver/ScreensaverContent.kt:773` honours it; +`ui/DetailPageComponents.kt:322` always fetches the logo. The screensaver also has +`useTextTitleForLogo`, a fallback for logos too dark to read — the detail hero has no +equivalent, so a dark logo is invisible on the near-black scrim. + +### 13. Two different 4K thresholds + +The badge fires at video width ≥ 3800 (`HomeComponents.kt:1184`); the `(4K)` suffix at +≥ 3400 (`DetailFacts.kt:190`). A 3600-wide file is 4K in the spec row and not on the badge. + +### 14. HDR10+ is named in `dynamicRangeLabel` but collapses to plain "HDR" in `mediaBadges` + +### 15. One `FocusRequester` attached to two live nodes + +`informationPane` is attached by the Overview pane, the Cast & Details pane and the Episodes +empty states (`ui/MediaDetailsOverlay.kt:202-216`, `ui/SeriesDetailsOverlay.kt:291-329`). +`AnimatedContent`'s 80ms fade-out keeps the outgoing pane composed, so a Down press landing +in that window can request focus on a pane that is disappearing. + +### 16. A series can never show format badges + +`ui/SeriesDetailsOverlay.kt:248` passes `badges = emptyList()` where movies pass real ones. +Related: `DetailFactRow` does `badges.take(3)`, so on a 4K/HDR/HEVC movie the airing badge +appended in `ui/MediaDetailsOverlay.kt:147` is silently dropped. + +### 17. Home row headers do not align + +`MediaRow` and `MyShowsStrip` lead with a 28dp icon chip plus 10dp; `RecentSearchesRow` +(`ui/MainActivity.kt:2084`) has no chip, so its title starts 38dp further left. It also uses +9dp header spacing against everyone else's 6dp and gives its `LazyRow` no vertical padding, +so focus-scaled chips have no room to grow. + +### 18. The mini hero labels are positional fiction + +`ui/HomeMovieHero.kt:124`: `listOf("POPULAR", "NEW RELEASE", "TRENDING")[index]`. But +`selectHomeHeroMovies` interleaves new releases and popular picks and then falls back to +every movie in the response. In the current screenshot a 2025 title is labelled NEW RELEASE +and a 2026 one POPULAR. + +### 19. A dead second home implementation + +`ui/MainActivity.kt:2752-2900`: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData` and +`HomeRowSkeleton` are unreferenced (only the gateway *model* named `HomeRow` is in use). +They carry a competing 48dp gutter and SemiBold header style — a live-looking template for +the wrong conventions. + +### 20. Tab content sits entirely below the fold with no affordance + +By design per the code comments, but nothing on screen tells the viewer that Down reveals +anything. + +--- + +## Documentation + +`CLAUDE.md`'s detail-page section no longer describes this code. It documents a poster-left +layout with Play hanging off the poster's bottom-right corner, "nothing scrolls vertically", +a tab list of "Overview, Episodes, Cast, Details", and a `DetailReasonStrip` of several +short phrases. The code is a full-bleed scrolling hero with Overview / Episodes / More Like +This / Cast & Details and a single reason line (`ui/DetailPageComponents.kt:375-385`). +Worth correcting before it misleads the next change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f963da --- /dev/null +++ b/LICENSE @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..7627b66 --- /dev/null +++ b/NOTICE @@ -0,0 +1,38 @@ +Memby +Copyright (C) 2026 Memby contributors + +Memby is free software licensed under the GNU General Public License, +version 2. A copy of that licence is included in the LICENSE file. + +Source code: +https://g.sublogue.com/admin/memby + +Wholphin +-------- + +Portions of Memby's Android playback capability probing and server-side +device-profile generation are adapted from Wholphin, an Android TV client +licensed under GNU GPL version 2: + +https://github.com/damontecres/Wholphin + +Wholphin's profiling implementation is itself derived from Jellyfin Android TV: + +https://github.com/jellyfin/jellyfin-androidtv + +The adapted Memby files identify both upstream projects, identify Memby's 2026 +modifications, and remain under GPLv2. Copyright in the upstream work remains +with its respective contributors. + +Third-party software +-------------------- + +The Android application uses AndroidX, Jetpack Compose, Media3, Coil, +Retrofit, OkHttp, kotlinx.serialization, and other open-source libraries. +Their copyright notices and licence metadata remain in their respective +source distributions and packaged dependency metadata. Those components +remain governed by their own licences; GPLv2 applies to Memby's original +and combined application code as required by the licence. + +Memby is an independent project and is not affiliated with or endorsed by +Emby LLC. Emby is a trademark of Emby LLC. diff --git a/README.md b/README.md index d39a876..f71cece 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ An independent Android TV client for Emby, by **ponzischeme89**. Sign in to brow personalised home screen, resume titles, play recent movies, and choose audio or subtitle tracks during playback. The screensaver (Dream) code remains isolated from the client work. +Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby) + ## Features - **System screensaver** via `DreamService` — auto-starts on idle once selected in the @@ -163,7 +165,9 @@ install. The build prints a warning saying so. ### Each release ```powershell -.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" -BaseUrl https://nas.example.com/memby +.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" ` + -BaseUrl https://nas.example.com/memby ` + -SourceUrl https://g.sublogue.com/admin/memby ``` That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and fills @@ -173,6 +177,8 @@ That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and index.html the page people are sent to latest.json the update manifest the app polls memby-0.1.54.apk the build +LICENSE GNU GPL v2 terms +NOTICE copyright and third-party acknowledgements ``` Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json` @@ -205,12 +211,26 @@ different app, so on every TV: ## Notes & limitations -- Playback uses Emby's direct stream (`/Videos/{id}/stream?static=true`). This direct-plays - containers/codecs ExoPlayer supports (most MP4/H.264, many MKV). Server-side transcoding - is not requested; unusual codecs may need it — a future enhancement is to call Emby's - `PlaybackInfo` endpoint and use the returned HLS transcode URL (the `media3-exoplayer-hls` - dependency is already included). +- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from + Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC + profiles, maximum levels and resolutions rather than assuming every decoder handles every + file. Emby can preserve a supported video stream while converting only incompatible audio + or subtitles. If a vendor decoder still fails, Media3 tries another decoder and Memby + ultimately requests an H.264 HLS transcode instead of abandoning playback. - Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only server this is unnecessary but harmless. - The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared. + +## Licence + +Memby is free software licensed under the [GNU General Public License v2](LICENSE). +Copyright and third-party acknowledgements are recorded in [NOTICE](NOTICE). Distributed +APKs and server binaries must be accompanied by the corresponding source in accordance +with GPLv2. The Android TV app also exposes the source link, notices, and complete licence +under **Settings → About / Licences**. + +Playback capability probing and device-profile generation contain GPLv2 adaptations from +[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from +[Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv). Attribution and +modification notices are preserved in the adapted source files. ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 927ecc3..3e98b63 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") + id("androidx.baselineprofile") } // Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...). @@ -15,9 +16,30 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?) // becomes a thin renderer; blank keeps the direct-to-Emby path above. val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim() +// Kept in BuildConfig so the TV can show the exact corresponding-source location and +// the complete legal documents offline. Deployments can override the public source URL +// without changing application code. +val membySourceUrl: String = + (project.findProperty("memby.sourceUrl") as String?) + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: "https://g.sublogue.com/admin/memby" + +fun buildConfigString(value: String): String = + "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + "\"" + +val gplLicenseText = rootProject.file("LICENSE").readText() +val projectNoticeText = + rootProject.file("NOTICE").readText() + .replace("https://g.sublogue.com/admin/memby", membySourceUrl) + // 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.1.69" +val defaultVersionName = "0.2.9" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -56,6 +78,9 @@ android { buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"") buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"") + buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl)) + buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText)) + buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText)) } // Release signing. Android identifies an app by (applicationId, signing key), so @@ -79,7 +104,13 @@ android { buildTypes { release { - isMinifyEnabled = false + // R8 is the single biggest cold-start lever on the weak TV boxes this ships + // to: a smaller dex is less to load and verify before the first frame. The + // keep rules in proguard-rules.pro are what stop it stripping the + // kotlinx.serialization models the gateway contract depends on — if you add a + // @Serializable package, add it there too. + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -155,6 +186,9 @@ dependencies { implementation("androidx.savedstate:savedstate-ktx:1.2.1") // Measurement only: JankStats is enabled by PerformanceMonitor for debug builds. implementation("androidx.metrics:metrics-performance:1.0.0") + // Installs the baseline profile below. Without it the profile is only honoured on + // API 31+; a TV box on Android 9-11 — most of the installed base — would get nothing. + implementation("androidx.profileinstaller:profileinstaller:1.4.1") // Compose (versions from BOM) implementation("androidx.compose.ui:ui") @@ -185,6 +219,12 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") + // Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real + // TV with `.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked + // in under app/src/release/generated/baselineProfiles so ordinary release builds + // don't need a device. + baselineProfile(project(":benchmark")) + testImplementation("junit:junit:4.13.2") // Screenshot rendering only. Everything else under app/src/test stays plain JUnit diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f6cc807..7542908 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,8 +1,46 @@ -# kotlinx.serialization keeps @Serializable metadata via generated serializers. --keepattributes *Annotation*, InnerClasses +# R8 rules for release builds (isMinifyEnabled = true). +# +# Most of what this app depends on ships its own consumer rules inside the AAR/JAR — +# Retrofit, OkHttp, Coil, Media3 and kotlinx.serialization all do. What follows is the +# part that is ours, plus a little insurance around reflection-shaped code. + +# --- kotlinx.serialization ----------------------------------------------------------- +# Every wire model is @Serializable and the gateway contract is pinned by field name, so +# a stripped or renamed serializer is a silent parse failure at runtime rather than a +# build error. The same models are also persisted to DataStore as JSON, which means an +# existing install decodes a blob written by the previous build. +# +# These rules match on the *annotation*, not on a package list. The previous version +# enumerated com.mattcohen.embyscreensaver.data.model — the package the app used through +# v0.1.53 — and so had quietly matched nothing at all. Serializable types today live in +# data.model, data and update; keying off @Serializable means moving one never breaks it. +-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault -dontnote kotlinx.serialization.** --keepclassmembers class **$$serializer { *; } --keepclasseswithmembers class com.mattcohen.embyscreensaver.data.model.** { + +-keep @kotlinx.serialization.Serializable class com.ponzischeme89.memby.** { *; } +-keepclassmembers class com.ponzischeme89.memby.** { + *** Companion; +} +-keepclasseswithmembers class com.ponzischeme89.memby.** { kotlinx.serialization.KSerializer serializer(...); } --keep,includedescriptorclasses class com.mattcohen.embyscreensaver.data.model.**$$serializer { *; } +-keepclassmembers class **$$serializer { *; } +-keep,includedescriptorclasses class com.ponzischeme89.memby.**$$serializer { *; } + +# --- Retrofit ------------------------------------------------------------------------ +# The API interfaces are consumed reflectively; their generic return types must survive +# or Retrofit cannot work out what to deserialize into. +-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.EmbyApi +-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi +-keepattributes Exceptions + +# --- Media3 / coroutines ------------------------------------------------------------- +-dontwarn androidx.media3.** +-dontwarn kotlinx.coroutines.** + +# --- Crash readability --------------------------------------------------------------- +# Releases are self-hosted with no crash reporter, so a stack trace read off a TV over +# adb is the only diagnostic there is. Line numbers cost a little dex size and are worth +# it; SourceFile is renamed so it does not leak original paths. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08713f7..53dbf5e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,8 +74,8 @@ android:theme="@style/Theme.Memby.Fullscreen" tools:ignore="DiscouragedApi" /> - + diff --git a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt index be58139..7117108 100644 --- a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt +++ b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt @@ -5,15 +5,31 @@ import coil.Coil import coil.ImageLoader import coil.disk.DiskCache import coil.memory.MemoryCache +import com.ponzischeme89.memby.data.remote.HttpStack +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit class MembyApp : Application() { override fun onCreate() { super.onCreate() Coil.setImageLoader( ImageLoader.Builder(this) + // Coil builds its own OkHttpClient when not given one, which would mean a + // third connection pool alongside the Emby and gateway APIs. In gateway + // mode artwork is proxied by the same HTTPS host that serves /v1/home, so + // sharing the stack lets every poster resume the connection the home + // request already opened rather than repeating the TLS handshake. + .okHttpClient { artworkHttpClient() } .memoryCache { MemoryCache.Builder(this) - .maxSizePercent(0.08) + // A backdrop is requested at 1280x720 — 3.7MB as ARGB_8888 — and + // it is replaced on every focus change. At the previous 8% the + // cache held barely two of them before a poster could be cached + // at all, so ordinary D-pad movement re-decoded artwork it had + // just evicted. These are hardware bitmaps (allowHardware is on + // at every call site), so the extra headroom is graphics memory + // rather than Java heap. + .maxSizePercent(0.25) .build() } .diskCache { @@ -31,4 +47,14 @@ class MembyApp : Application() { ) ServiceLocator.init(this) } + + /** + * Artwork tolerates a shorter read timeout than the API does: a poster that has not + * arrived in ten seconds has already missed the moment it was wanted for, and the + * card falls back to its placeholder. + */ + private fun artworkHttpClient(): OkHttpClient = HttpStack.base.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() } 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 9ea5526..4c939d1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -3,24 +3,27 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.AuthRequest import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayFlagRequest -import com.ponzischeme89.memby.data.model.GatewayAuthError -import com.ponzischeme89.memby.data.model.GatewayAuthPolicy +import com.ponzischeme89.memby.data.model.GatewayDevice +import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayRowEvent import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayServiceStatus -import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.data.model.GatewayFeatures import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.PlaybackReport import com.ponzischeme89.memby.data.model.PlaybackInfoRequest +import com.ponzischeme89.memby.data.model.MediaSourceInfo 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.playback.devicePlaybackCapabilities import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -80,6 +83,11 @@ data class Playable( val mediaSourceId: String = "", val playSessionId: String = "", val playMethod: String = "DirectPlay", + val overview: String? = null, + val episodeCode: String? = null, + val runtimeMs: Long = 0L, + val prerollEnabled: Boolean = true, + val prerollDurationMs: Long = 6_500L, ) class EmbyRepository(private val settings: SettingsStore) { @@ -96,11 +104,24 @@ class EmbyRepository(private val settings: SettingsStore) { } val settingsFlow: Flow get() = settings.settingsFlow + + /** + * Read synchronously off the snapshot, like [rotationIntervalMillis], because the + * composables that decide between logo artwork and a text title do so while building a + * layout and must not collect a flow to answer a question that changes once a year. + */ + val showTitleLogo: Boolean get() = snapshot.showTitleLogo private val _playbackStops = MutableSharedFlow(extraBufferCapacity = 1) val playbackStops = _playbackStops.asSharedFlow() private val playableMutex = Mutex() private val playableCache = LinkedHashMap(16, 0.75f, true) private val playableInFlight = mutableMapOf>() + private val seriesEpisodesMutex = Mutex() + private val seriesEpisodesCache = + LinkedHashMap(SERIES_EPISODE_CACHE_SIZE, 0.75f, true) + private val relatedMutex = Mutex() + private val relatedCache = + LinkedHashMap(RELATED_CACHE_SIZE, 0.75f, true) fun cachedHome(): HomeCache? = settings.homeCache(snapshot) @@ -168,14 +189,14 @@ class EmbyRepository(private val settings: SettingsStore) { deviceName: String, ): String { clearPlayableCache() + clearSeriesEpisodeCache() settings.ensureDeviceId() observedSettings = settings.snapshot() // pick up the freshly-generated device id ServerConfig.gatewayUrl?.let { gateway -> // The gateway holds the Emby token; this device only ever stores the gateway // token, so the same `token` slot in DataStore serves both modes. - val result = try { - requireGateway().login( + val result = requireGateway().login( GatewayLoginRequest( username = username, password = password, @@ -183,12 +204,6 @@ class EmbyRepository(private val settings: SettingsStore) { deviceName = deviceName.trim(), ), ) - } catch (error: HttpException) { - if (error.code() == 409) { - parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it } - } - throw error - } require(result.token.isNotBlank() && result.userId.isNotBlank()) { "Gateway did not return a session" } @@ -220,13 +235,39 @@ class EmbyRepository(private val settings: SettingsStore) { return username } - suspend fun authPolicy(): GatewayAuthPolicy? = - if (ServerConfig.isGateway) requireGateway().authPolicy() else null + suspend fun devices(): List = + if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList() - /** True only when the gateway still recognises the token restored from storage. */ + suspend fun removeDevice(deviceId: String) { + if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId) + } + + suspend fun renameDevice(device: GatewayDevice, deviceName: String) { + val trimmed = deviceName.trim() + require(trimmed.isNotEmpty() && trimmed.length <= 80) { "Invalid device name" } + if (ServerConfig.isGateway) { + requireGateway().renameDevice(device.deviceId, GatewayDeviceNameRequest(trimmed)) + if (device.current) settings.setDeviceName(trimmed) + } + } + + /** + * False only when the gateway explicitly rejects the restored token with 401. + * + * A deployment, maintenance window, or disconnected TV is not evidence that a + * viewer's credentials are invalid. Treating every network failure as rejection used + * to delete the active profile precisely when the server was being updated. + */ suspend fun validateSession(): Boolean { if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true - return runCatching { requireGateway().session() }.isSuccess + return try { + requireGateway().session() + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + shouldPreserveSessionAfterValidationFailure(failure) + } } /** @@ -239,6 +280,7 @@ class EmbyRepository(private val settings: SettingsStore) { cachedApi = null cachedBaseUrl = null clearPlayableCache() + clearSeriesEpisodeCache() } suspend fun signOut() { @@ -252,6 +294,7 @@ class EmbyRepository(private val settings: SettingsStore) { cachedApi = null cachedBaseUrl = null clearPlayableCache() + clearSeriesEpisodeCache() } suspend fun switchProfile(profile: EmbyProfile) { @@ -260,6 +303,24 @@ class EmbyRepository(private val settings: SettingsStore) { cachedApi = null cachedBaseUrl = null clearPlayableCache() + clearSeriesEpisodeCache() + } + + suspend fun removeProfile(profile: EmbyProfile) { + val removingActiveProfile = snapshot.activeProfileId == profile.id + if (removingActiveProfile && ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) { + // Retire the active gateway session where possible. Local removal still + // succeeds when this TV is offline. + runCatching { requireGateway().logout() } + } + settings.removeProfile(profile.id) + observedSettings = settings.snapshot() + if (removingActiveProfile) { + cachedApi = null + cachedBaseUrl = null + clearPlayableCache() + clearSeriesEpisodeCache() + } } // --- Content ------------------------------------------------------------- @@ -345,7 +406,7 @@ class EmbyRepository(private val settings: SettingsStore) { "SortOrder" to "Ascending", "Limit" to limit.toString(), ), - fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio", + fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio", imageTypes = "Backdrop,Primary,Logo", includeUserData = true, ) @@ -443,6 +504,23 @@ class EmbyRepository(private val settings: SettingsStore) { .getOrDefault(emptyList()) } + suspend fun lookupMediaRequests(term: String): List { + if (!ServerConfig.isGateway) return emptyList() + return requireGateway().requestLookup(term.trim()).candidates + } + + suspend fun requestMedia( + candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate, + ): String { + check(ServerConfig.isGateway) { "Media requests require the Memby gateway" } + return requireGateway().requestMedia( + com.ponzischeme89.memby.data.model.GatewayMediaRequest( + mediaType = candidate.mediaType, + foreignId = candidate.foreignId, + ), + ).title + } + /** * Recommendation rows on their own, forcing the gateway to build them synchronously * if its cache is cold. [getHome] already carries them once warm, so this is only @@ -456,21 +534,72 @@ class EmbyRepository(private val settings: SettingsStore) { return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows } - /** - * The gateway's verdict on this build. Null on the direct path, where nobody is in a - * position to decide, and null on failure — an unreachable gateway must never leave - * a TV stuck behind a blocking update prompt. - */ - suspend fun checkAppUpdate(): GatewayUpdate? { - if (!ServerConfig.isGateway) return null - return runCatching { requireGateway().updateStatus() } - .getOrNull() - ?.takeIf { it.isActionable } + suspend fun getRecommendationOnboarding(): + com.ponzischeme89.memby.data.model.RecommendationOnboarding { + if (!ServerConfig.isGateway) { + return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true) + } + return requireGateway().recommendationPreferences() } + suspend fun saveRecommendationRatings(ratings: Map) { + if (!ServerConfig.isGateway) return + requireGateway().saveRecommendationPreferences( + com.ponzischeme89.memby.data.model.RecommendationPreferences( + ratings = ratings.filterValues { it in 1..5 }, + ), + ) + } + + suspend fun getMyShows(): List = + if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList() + + suspend fun saveMyShow(item: BaseItem): List { + if (!ServerConfig.isGateway || !item.isSeries) return getMyShows() + return requireGateway().saveMyShow( + com.ponzischeme89.memby.data.model.SaveMyShowRequest( + itemId = item.id, + title = item.name, + year = item.productionYear, + imageTag = item.imageTags["Primary"].orEmpty(), + ), + ).shows + } + + suspend fun removeMyShow(itemId: String) { + if (ServerConfig.isGateway) requireGateway().removeMyShow(itemId) + } + + suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse = + if (ServerConfig.isGateway) { + requireGateway().notifications() + } else { + com.ponzischeme89.memby.data.model.NotificationsResponse() + } + + suspend fun setNotificationPreferences( + value: com.ponzischeme89.memby.data.model.NotificationPreferences, + ): com.ponzischeme89.memby.data.model.NotificationsResponse = + requireGateway().setNotificationPreferences(value) + + suspend fun markNotificationRead(id: Long) { + if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read") + } + + suspend fun dismissNotification(id: Long) { + if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss") + } + + fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? = + imageTag.takeIf(String::isNotBlank)?.let { + imageUrl(itemId, "Primary", it, maxWidth) + } + /** Live gateway state. Unlike content routes, this remains available in maintenance. */ suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus() + suspend fun serverFeatures(): GatewayFeatures = requireGateway().features() + /** Full item metadata, requested only after focus settles on an item. */ suspend fun getItemDetails(itemId: String): BaseItem { if (ServerConfig.isGateway) return requireGateway().item(itemId) @@ -478,32 +607,142 @@ class EmbyRepository(private val settings: SettingsStore) { return requireApi().getItem( userId = userId, itemId = itemId, - fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio", + fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName", ) } + /** Whether this episode closes its season; failures are non-fatal playback metadata. */ + suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale { + if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale() + if (ServerConfig.isGateway) return requireGateway().seasonFinale(itemId) + + // Direct mode has no Sonarr catalogue. Use Emby's full episode list as the best + // available fallback; gateway mode remains authoritative because it sees future + // episodes and cannot confuse the newest download with a finale. + val current = getItemDetails(itemId) + val season = current.parentIndexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale() + val episode = current.indexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale() + val seriesId = current.seriesId ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale() + val lastEpisode = getSeriesEpisodes(seriesId) + .filter { it.parentIndexNumber == season } + .mapNotNull(BaseItem::indexNumber) + .maxOrNull() + if (season <= 0 || lastEpisode == null || episode != lastEpisode) { + return com.ponzischeme89.memby.data.model.GatewaySeasonFinale() + } + return com.ponzischeme89.memby.data.model.GatewaySeasonFinale( + seasonFinale = true, + seriesName = current.seriesName.orEmpty(), + seasonNumber = season, + episodeNumber = episode, + ) + } + + /** + * The detail page's "why you might like this" strip and its related carousel. + * + * Dual-path like everything else, but the two paths differ in what they can say: the + * gateway runs the recommendation engine and returns reasons drawn from this user's + * own history, while direct mode has only Emby's similarity ranking and therefore no + * reasons at all. An empty result is normal, never an error — a detail page that + * cannot explain itself still has to open. + */ + suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent { + if (item.id.isBlank()) return RelatedContent() + val now = System.currentTimeMillis() + relatedMutex.withLock { + relatedCache[item.id] + ?.takeIf { it.expiresAtMs > now } + ?.let { return it.content } + relatedCache.remove(item.id) + } + + val loaded = runCatching { + if (ServerConfig.isGateway) { + val response = requireGateway().related(item.id) + RelatedContent( + reasons = response.reasons.filter(String::isNotBlank), + items = response.items.filter { it.id != item.id }, + ) + } else { + val userId = snapshot.userId ?: error("Not connected") + RelatedContent( + items = requireApi().getSimilar( + itemId = item.id, + params = mapOf( + "UserId" to userId, + "Limit" to limit.toString(), + "Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName", + "EnableUserData" to "true", + "EnableImages" to "true", + "EnableImageTypes" to "Primary,Backdrop,Logo", + "ImageTypeLimit" to "1", + ), + ).items.filter { it.id != item.id }, + ) + } + }.getOrElse { RelatedContent() } + + relatedMutex.withLock { + relatedCache[item.id] = CachedRelated( + content = loaded.copy(items = loaded.items.take(limit)), + expiresAtMs = now + RELATED_CACHE_TTL_MS, + ) + while (relatedCache.size > RELATED_CACHE_SIZE) { + relatedCache.entries.iterator().run { + next() + remove() + } + } + return relatedCache.getValue(item.id).content + } + } + /** * All episodes for one show in a single request. Season switching is then a local * list filter, keeping the detail screen immediate after its first load. */ suspend fun getSeriesEpisodes(seriesId: String): List { if (seriesId.isBlank()) return emptyList() - if (ServerConfig.isGateway) { - return requireGateway().seriesEpisodes(seriesId).items + val now = System.currentTimeMillis() + seriesEpisodesMutex.withLock { + seriesEpisodesCache[seriesId] + ?.takeIf { it.expiresAtMs > now } + ?.episodes + ?.let { return it } + seriesEpisodesCache.remove(seriesId) } - val userId = snapshot.userId ?: error("Not connected") - return requireApi().getEpisodes( - seriesId, - mapOf( - "UserId" to userId, - "Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio", - "EnableUserData" to "true", - "EnableImages" to "true", - "EnableImageTypes" to "Primary,Thumb,Backdrop", - "ImageTypeLimit" to "1", - "Limit" to "1000", - ), - ).items + + val loaded = if (ServerConfig.isGateway) { + requireGateway().seriesEpisodes(seriesId).items + } else { + val userId = snapshot.userId ?: error("Not connected") + requireApi().getEpisodes( + seriesId, + mapOf( + "UserId" to userId, + "Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + "EnableUserData" to "true", + "EnableImages" to "true", + "EnableImageTypes" to "Primary,Thumb,Backdrop", + "ImageTypeLimit" to "1", + "Limit" to "1000", + ), + ).items + } + seriesEpisodesMutex.withLock { + seriesEpisodesCache[seriesId] = CachedSeriesEpisodes( + episodes = loaded, + expiresAtMs = now + SERIES_EPISODE_CACHE_TTL_MS, + ) + while (seriesEpisodesCache.size > SERIES_EPISODE_CACHE_SIZE) { + seriesEpisodesCache.entries.iterator().run { + next() + remove() + } + } + } + return loaded } /** Tight list endpoint shape: detail-only fields are never fetched on home. */ @@ -597,6 +836,16 @@ class EmbyRepository(private val settings: SettingsStore) { .getOrDefault(GatewayPrerollSchedule()) } + fun prerollArtworkUrl(itemId: String, imageType: String, maxWidth: Int): String? { + if (!ServerConfig.isGateway || itemId.isBlank() || imageType.isBlank()) return null + return imageUrl( + itemId = itemId, + imageType = imageType, + tag = "sonarr", + maxWidth = maxWidth, + ) + } + /** Backdrop rotation interval, clamped to a sane range. */ fun rotationIntervalMillis(): Long = snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L @@ -641,14 +890,15 @@ class EmbyRepository(private val settings: SettingsStore) { itemId: String, title: String, resumePositionMs: Long, + forceTranscode: Boolean = false, ): Playable { require(itemId.isNotBlank()) { "A media item is required to refresh playback" } if (!ServerConfig.isGateway) { - val discovery = directPlayback(itemId, resumePositionMs) + val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode) return Playable( itemId = itemId, title = title, - url = buildStreamUrl(itemId), + url = discovery.url ?: buildStreamUrl(itemId), resumePositionMs = resumePositionMs.coerceAtLeast(0L), subtitles = discovery.subtitles, mediaSourceId = discovery.mediaSourceId, @@ -662,6 +912,7 @@ class EmbyRepository(private val settings: SettingsStore) { itemType = "", title = title, resumePositionMs = resumePositionMs.coerceAtLeast(0L), + forceTranscode = forceTranscode, ) return Playable( itemId = playback.itemId, @@ -700,6 +951,9 @@ class EmbyRepository(private val settings: SettingsStore) { mediaSourceId = playback.mediaSourceId, playSessionId = playback.playSessionId, playMethod = playback.playMethod, + overview = playback.overview.ifBlank { null }, + episodeCode = playback.episodeCode.ifBlank { null }, + runtimeMs = playback.runtimeMs, ) } val discovery = directPlayback( @@ -765,6 +1019,12 @@ class EmbyRepository(private val settings: SettingsStore) { mediaSourceId = playback.mediaSourceId, playSessionId = playback.playSessionId, playMethod = playback.playMethod, + overview = playback.overview.ifBlank { item.overview }, + episodeCode = playback.episodeCode.ifBlank { episodeCode(item) }, + runtimeMs = playback.runtimeMs.takeIf { it > 0L } + ?: item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, + prerollEnabled = playback.prerollEnabled, + prerollDurationMs = playback.prerollDurationMs, ) } if (item.isSeries) { @@ -777,28 +1037,34 @@ class EmbyRepository(private val settings: SettingsStore) { } val discovery = directPlayback(episode.id, episode.resumePositionMs) return Playable( - episode.id, - title, - buildStreamUrl(episode.id), - episode.resumePositionMs, - logoUrl(item), - discovery.subtitles, - discovery.mediaSourceId, - discovery.playSessionId, - discovery.playMethod, + itemId = episode.id, + title = title, + url = discovery.url ?: buildStreamUrl(episode.id), + resumePositionMs = episode.resumePositionMs, + logoUrl = logoUrl(item), + subtitles = discovery.subtitles, + mediaSourceId = discovery.mediaSourceId, + playSessionId = discovery.playSessionId, + playMethod = discovery.playMethod, + overview = episode.overview, + episodeCode = episodeCode(episode), + runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, ) } val discovery = directPlayback(item.id, item.resumePositionMs) return Playable( - item.id, - item.name, - buildStreamUrl(item.id), - item.resumePositionMs, - logoUrl(item), - discovery.subtitles, - discovery.mediaSourceId, - discovery.playSessionId, - discovery.playMethod, + itemId = item.id, + title = item.name, + url = discovery.url ?: buildStreamUrl(item.id), + resumePositionMs = item.resumePositionMs, + logoUrl = logoUrl(item), + subtitles = discovery.subtitles, + mediaSourceId = discovery.mediaSourceId, + playSessionId = discovery.playSessionId, + playMethod = discovery.playMethod, + overview = item.overview, + episodeCode = episodeCode(item), + runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, ) } @@ -810,6 +1076,16 @@ class EmbyRepository(private val settings: SettingsStore) { } } + private suspend fun clearSeriesEpisodeCache() { + seriesEpisodesMutex.withLock { + seriesEpisodesCache.clear() + } + relatedMutex.withLock { + // Reasons are personal: another profile must never inherit this one's. + relatedCache.clear() + } + } + suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) { if (ServerConfig.isGateway) { requireGateway().report("started", session.gatewayReport(positionMs, false, null)) @@ -823,12 +1099,16 @@ class EmbyRepository(private val settings: SettingsStore) { positionMs: Long, isPaused: Boolean, eventName: String, - ) { + durationMs: Long = 0L, + ): String? { if (ServerConfig.isGateway) { - requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName)) - return + return requireGateway().report( + "progress", + session.gatewayReport(positionMs, isPaused, eventName, durationMs), + ).autoFollowedShowTitle.takeIf(String::isNotBlank) } requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName)) + return null } suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) { @@ -842,6 +1122,8 @@ class EmbyRepository(private val settings: SettingsStore) { } } finally { clearPlayableCache() + // Episode progress and watched badges may have changed during playback. + clearSeriesEpisodeCache() _playbackStops.tryEmit(session.itemId) } } @@ -894,7 +1176,7 @@ class EmbyRepository(private val settings: SettingsStore) { val next = episodes.getOrNull(current + 1) ?: return null val discovery = directPlayback(next.id, next.resumePositionMs) return nextEpisodeOf( - next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles, + next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles, discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod, ) } @@ -926,6 +1208,7 @@ class EmbyRepository(private val settings: SettingsStore) { positionMs: Long, subtitleStreamIndex: Int? = null, currentPlaySessionId: String? = null, + forceTranscode: Boolean = false, ): PlaybackDiscovery { val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId) val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId) @@ -938,11 +1221,27 @@ class EmbyRepository(private val settings: SettingsStore) { id = itemId, userId = userId, startTimeTicks = millisecondsToTicks(positionMs), + enableDirectPlay = !forceTranscode, + enableDirectStream = !forceTranscode, subtitleStreamIndex = subtitleStreamIndex, currentPlaySessionId = currentPlaySessionId, + deviceProfile = if (forceTranscode) { + com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv( + capabilities = devicePlaybackCapabilities, + ) + .copy(directPlayProfiles = emptyList()) + } else { + com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv( + capabilities = devicePlaybackCapabilities, + ) + }, ), ) info.mediaSources.firstOrNull()?.let { source -> + val delivery = selectPlaybackDelivery( + source = source, + forceTranscode = forceTranscode || subtitleStreamIndex != null, + ) PlaybackDiscovery( subtitles = subtitleTracks( streams = source.mediaStreams, @@ -953,12 +1252,8 @@ class EmbyRepository(private val settings: SettingsStore) { ), mediaSourceId = source.id.ifBlank { itemId }, playSessionId = info.playSessionId, - playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) { - "Transcode" - } else "DirectPlay", - url = source.transcodingUrl - ?.takeIf { subtitleStreamIndex != null } - ?.let { authenticatedDeliveryUrl(serverUrl, it, token) }, + playMethod = delivery.playMethod, + url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) }, ) } ?: PlaybackDiscovery(mediaSourceId = itemId) }.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) } @@ -1026,7 +1321,7 @@ class EmbyRepository(private val settings: SettingsStore) { /** * Poster for an item the app never received as a [BaseItem] — a service alert names - * its subject by id and tag only, and the Sonarr ids it carries resolve through the + * its subject by id and tag only, and the TV schedule ids it carries resolve through the * gateway's image proxy like any other. */ fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? { @@ -1060,7 +1355,7 @@ class EmbyRepository(private val settings: SettingsStore) { append(gateway.trimEnd('/')) append("/v1/images/").append(itemId).append('/').append(imageType.lowercase()) append("?maxWidth=").append(maxWidth) - append("&quality=90") + append("&quality=").append(ARTWORK_QUALITY) append("&tag=").append(encode(tag)) append("&t=").append(encode(token)) } @@ -1072,7 +1367,7 @@ class EmbyRepository(private val settings: SettingsStore) { append("/Items/").append(itemId).append("/Images/").append(imageType) if (directIndex) append("/0") append("?maxWidth=").append(maxWidth) - append("&quality=90") + append("&quality=").append(ARTWORK_QUALITY) append("&tag=").append(encode(tag)) token?.let { append("&api_key=").append(encode(it)) } } @@ -1109,6 +1404,22 @@ class EmbyRepository(private val settings: SettingsStore) { ) private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") + + private companion object { + /** + * JPEG quality asked of Emby (or of the gateway's image proxy) for every poster, + * backdrop and logo. + * + * 80 rather than 90: at the distance a television is watched from the two are + * indistinguishable, and it is roughly a third fewer bytes on every cold image — + * which is time-to-poster on a launcher that paints fifteen of them at once, and + * a disk cache that holds correspondingly more. + * + * Note this is part of the image URL, and the URL is Coil's cache key, so + * changing it makes every existing install re-fetch its artwork once. + */ + const val ARTWORK_QUALITY = 80 + } } data class PlaybackSession( @@ -1126,10 +1437,52 @@ private data class PlaybackDiscovery( val url: String? = null, ) -private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) = +internal data class PlaybackDelivery( + val url: String?, + val playMethod: String, +) + +/** + * Honour Emby's negotiated delivery instead of blindly opening the original file. + * A null URL means the original static stream is genuinely suitable for direct play. + */ +internal fun selectPlaybackDelivery( + source: MediaSourceInfo, + forceTranscode: Boolean = false, +): PlaybackDelivery { + val transcode = source.transcodingUrl?.takeIf(String::isNotBlank) + val directStream = source.directStreamUrl?.takeIf(String::isNotBlank) + if (forceTranscode && transcode != null) { + return PlaybackDelivery(transcode, "Transcode") + } + if (source.supportsDirectPlay == true) { + return PlaybackDelivery(null, "DirectPlay") + } + if (source.supportsDirectStream != false && directStream != null) { + return PlaybackDelivery(directStream, "DirectStream") + } + if (source.supportsTranscoding != false && transcode != null) { + return PlaybackDelivery(transcode, "Transcode") + } + if (directStream != null) { + return PlaybackDelivery(directStream, "DirectStream") + } + if (transcode != null) { + return PlaybackDelivery(transcode, "Transcode") + } + return PlaybackDelivery(null, "DirectPlay") +} + +private fun PlaybackSession.gatewayReport( + positionMs: Long, + isPaused: Boolean, + eventName: String?, + durationMs: Long = 0L, +) = GatewayPlaybackReport( itemId = itemId, positionMs = positionMs, + durationMs = durationMs.coerceAtLeast(0L), isPaused = isPaused, mediaSourceId = mediaSourceId, playSessionId = playSessionId, @@ -1137,13 +1490,51 @@ private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, e eventName = eventName, ) +private fun episodeCode(item: BaseItem): String? { + val season = item.parentIndexNumber ?: return null + val episode = item.indexNumber ?: return null + if (season < 0 || episode <= 0) return null + return "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}" +} + private data class CachedPlayable( val playable: Playable, val expiresAtMs: Long, ) +private data class CachedSeriesEpisodes( + val episodes: List, + val expiresAtMs: Long, +) + +/** + * What a detail page can add beyond the item itself. + * + * [reasons] is the recommendation engine's explanation of this title *to this viewer* and + * is empty on the direct path, which has no profile to reason from. + */ +data class RelatedContent( + val reasons: List = emptyList(), + val items: List = emptyList(), +) { + val isEmpty: Boolean get() = reasons.isEmpty() && items.isEmpty() +} + +private data class CachedRelated( + val content: RelatedContent, + val expiresAtMs: Long, +) + private const val PLAYABLE_CACHE_SIZE = 16 private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L +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 +private const val RELATED_LIMIT = 12 + +// Long enough that walking back and forth between a row and a detail page never re-asks, +// short enough that a newly watched title drops out of "more like this" the same evening. +private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L internal fun millisecondsToTicks(milliseconds: Long): Long = milliseconds.coerceAtLeast(0L) * 10_000L @@ -1151,29 +1542,9 @@ internal fun millisecondsToTicks(milliseconds: Long): Long = private val BaseItem.resumePositionMs: Long get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) -class DeviceLimitException( - val activeClients: Int, - val maxClients: Int, -) : Exception("Memby device allowance reached") - /** Shared across the error-body parsers below; building a Json format per call is costly. */ private val errorBodyJson = Json { ignoreUnknownKeys = true } -internal fun parseDeviceLimit(body: String): DeviceLimitException? { - if (body.isBlank()) return null - return runCatching { - val parsed = errorBodyJson.decodeFromString(body) - parsed.takeIf { - it.error == "device_limit_reached" && it.maxClientsPerUser > 0 - }?.let { - DeviceLimitException( - activeClients = it.activeClients.coerceAtLeast(0), - maxClients = it.maxClientsPerUser, - ) - } - }.getOrNull() -} - /** * Maps an exception to a short, TV-readable message. Never surfaces raw HTTP * bodies or stack traces (which could contain tokens) to the screen. @@ -1217,6 +1588,10 @@ fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() = /** True when the gateway has rejected the persisted session token. */ fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401 +/** Only an authoritative authentication rejection permits deleting a saved profile. */ +internal fun shouldPreserveSessionAfterValidationFailure(t: Throwable): Boolean = + !isUnauthorizedError(t) + @Serializable private data class MaintenanceResponse( val maintenance: Boolean = false, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index f610880..ad2b067 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -20,14 +20,19 @@ data class MaintenanceNotice(val message: String) data class CompatibilityNotice(val message: String) /** - * One informational banner: a show aired, its episode is on its way into Emby. It is - * never actionable and never focusable — it slides in, says its piece and goes. + * One informational banner: a show aired, a film was added, the library finished + * refreshing, the server stopped answering. It is never actionable and never focusable — + * it slides in, says its piece and goes, over the launcher or over playback alike. + * + * [label] is the eyebrow above the title and comes from the server, so a kind of news + * this build has never heard of still reads correctly; a server that sends none gets the + * original wording back. */ data class ServiceAlert( val id: String, val title: String, val message: String, - val posterUrl: String?, + val label: String = "", ) /** @@ -177,7 +182,10 @@ class MaintenanceMonitor( id = next.id, title = next.title.trim(), message = next.message.trim(), - posterUrl = repository.posterUrl(next.itemId, next.imageTag), + // The banner shows the Emby mark rather than item artwork — half these + // alerts (a refresh, an outage) have no artwork — so the itemId and + // imageTag the gateway still sends are deliberately unused here. + label = next.label.trim(), ) } 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 e21963a..eab683d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -2,15 +2,19 @@ package com.ponzischeme89.memby.data import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -25,7 +29,20 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.UUID -private val Context.dataStore: DataStore by preferencesDataStore(name = "emby_settings") +/** + * The corruption handler is not optional here. Without one, a Preferences file that fails + * to parse makes [DataStore.data] throw on every read for the life of the install — and + * because [SettingsStore.settingsFlow] is the gate the launcher waits behind, that shows + * up as "Opening Memby…" forever, surviving every relaunch. Preferences DataStore rewrites + * and fsyncs the whole file on each edit, and this app edits it on every home refresh, so + * a process killed mid-write (backgrounding a TV app is exactly when that happens) is a + * real possibility rather than a theoretical one. Starting again from empty preferences + * costs a sign-in; throwing costs the app. + */ +private val Context.dataStore: DataStore by preferencesDataStore( + name = "emby_settings", + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) /** Persisted connection state. */ data class Settings( @@ -46,6 +63,8 @@ data class Settings( val showTitleLogo: Boolean = true, // Slide up a "next up" banner near the end of an episode and roll into the next one. val autoPlayNextEpisode: Boolean = true, + // Show the compact lower-third when playback crosses ten minutes remaining. + val showTenMinuteReminder: Boolean = true, // Foreground colour of the slide-progress ring, as an RRGGBB hex string. val ringColorHex: String = DEFAULT_RING_COLOR, val lastBackdropUrl: String? = null, @@ -57,7 +76,25 @@ data class Settings( /** Whether this profile has discovered the dedicated For You destination. */ val hasOpenedForYou: Boolean = false, val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY, + /** Card image selection on browse rows: automatic, poster, or backdrop. */ + val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE, val showHomeCardMetadata: Boolean = true, + /** Hide fully watched movies from browse rows and hero selections. */ + val hideWatchedMovies: Boolean = false, + /** Newline-separated server row ids. These are profile-specific home customisations. */ + val homeRowOrder: String = "", + val homePinnedRows: String = "", + val homeHiddenRows: String = "", + /** Tone used for the short welcome line after sign-in and during startup. */ + val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE, + /** + * User ids known to have finished recommendation onboarding on this TV. Cold start + * consults this instead of waiting on the gateway: onboarding is a one-time, + * monotonic fact, and blocking the launcher on a round-trip to re-learn it defeats + * the whole point of [homeCacheJson]. The gateway is still asked in the background + * and remains authoritative for anyone not listed here. + */ + val onboardedUserIds: Set = emptySet(), val profiles: List = emptyList(), ) { val isSignedIn: Boolean @@ -68,11 +105,17 @@ data class Settings( it.userId == userId && it.serverUrl == serverUrl }?.id + /** True once this TV has seen the active profile finish onboarding. */ + val hasCompletedOnboarding: Boolean + get() = userId?.let { it in onboardedUserIds } == true + companion object { const val DEFAULT_ROTATION_SECONDS = 15 const val DEFAULT_RING_COLOR = "FFFFFF" const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest" const val DEFAULT_HOME_CARD_DENSITY = "standard" + const val DEFAULT_HOME_ARTWORK_STYLE = "automatic" + const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral" val EMPTY = Settings() } } @@ -88,6 +131,15 @@ data class EmbyProfile( val homeCacheJson: String? = null, val forYouMinutes: Int = 0, val hasOpenedForYou: Boolean = false, + val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE, + val homeSections: String = Settings.DEFAULT_HOME_SECTIONS, + val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY, + val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, + val showHomeCardMetadata: Boolean = true, + val hideWatchedMovies: Boolean = false, + val homeRowOrder: String = "", + val homePinnedRows: String = "", + val homeHiddenRows: String = "", ) class SettingsStore(private val context: Context) { @@ -95,6 +147,14 @@ class SettingsStore(private val context: Context) { @Volatile private var latestSettings: Settings? = null + /** Last home cache actually written, so an unchanged refresh skips the disk entirely. */ + @Volatile + private var lastPersistedHomeCache: String? = null + + /** The decoded home cache, paired with the JSON it came from. See [primeHomeCache]. */ + @Volatile + private var decodedHomeCache: Pair? = null + val current: Settings? get() = latestSettings @@ -113,6 +173,7 @@ class SettingsStore(private val context: Context) { val UPDATE_TOKEN = stringPreferencesKey("update_token") val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo") val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode") + val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder") val RING_COLOR = stringPreferencesKey("ring_color") val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") val HOME_SECTIONS = stringPreferencesKey("home_sections") @@ -120,9 +181,16 @@ class SettingsStore(private val context: Context) { val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes") val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you") val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density") + val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style") val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata") + val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies") + val HOME_ROW_ORDER = stringPreferencesKey("home_row_order") + val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows") + val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows") + val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style") val PROFILES = stringPreferencesKey("profiles") val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids") + val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids") } /** @@ -134,7 +202,24 @@ class SettingsStore(private val context: Context) { val settingsFlow: Flow = context.dataStore.data .map(::settingsFrom) .distinctUntilChanged() - .onEach { latestSettings = it } + .onEach { + latestSettings = it + // Decode the launcher's cached rows here, on this store's IO scope, rather + // than leaving it for HomeViewModel's constructor — which Compose runs on the + // main thread during the first composition, parsing a blob that can hold + // every row and every item in it. AppRoot does not compose HomeScreen until + // this flow has emitted, so by the time the view model asks, the answer is + // already in memory. + primeHomeCache(it) + } + // A throw here would complete the sharing coroutine, and a SharedFlow that has + // completed never emits again — every collector for the rest of the process waits + // forever, which the launcher renders as its loading screen. Nothing this pipeline + // does is worth that, so a failed read degrades to defaults and the app carries on + // to sign-in instead of hanging. The corruption handler on [dataStore] covers the + // parse failure; this covers everything else, including an I/O error on a TV whose + // storage is briefly unavailable. + .catch { emit(Settings.EMPTY.also { latestSettings = it }) } .shareIn(scope, started = SharingStarted.Eagerly, replay = 1) suspend fun setRotationIntervalSeconds(seconds: Int) { @@ -189,6 +274,10 @@ class SettingsStore(private val context: Context) { context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled } } + suspend fun setShowTenMinuteReminder(enabled: Boolean) { + context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled } + } + suspend fun setRingColor(hex: String) { context.dataStore.edit { it[Keys.RING_COLOR] = hex } } @@ -199,41 +288,140 @@ class SettingsStore(private val context: Context) { suspend fun setHomeSections(sections: List) { val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct() - context.dataStore.edit { - it[Keys.HOME_SECTIONS] = valid.ifEmpty { listOf("favorites") }.joinToString(",") + val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",") + context.dataStore.edit { preferences -> + preferences[Keys.HOME_SECTIONS] = selected + updateActiveProfile(preferences) { it.copy(homeSections = selected) } } } suspend fun setHomeCardDensity(density: String) { - context.dataStore.edit { - it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") } - ?: Settings.DEFAULT_HOME_CARD_DENSITY + val selected = density.takeIf { it in setOf("compact", "standard", "large") } + ?: Settings.DEFAULT_HOME_CARD_DENSITY + context.dataStore.edit { preferences -> + preferences[Keys.HOME_CARD_DENSITY] = selected + updateActiveProfile(preferences) { it.copy(homeCardDensity = selected) } + } + } + + suspend fun setHomeArtworkStyle(style: String) { + val selected = style.takeIf { it in setOf("automatic", "poster", "backdrop") } + ?: Settings.DEFAULT_HOME_ARTWORK_STYLE + context.dataStore.edit { preferences -> + preferences[Keys.HOME_ARTWORK_STYLE] = selected + updateActiveProfile(preferences) { it.copy(homeArtworkStyle = selected) } } } suspend fun setShowHomeCardMetadata(show: Boolean) { - context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show } + context.dataStore.edit { preferences -> + preferences[Keys.SHOW_HOME_CARD_METADATA] = show + updateActiveProfile(preferences) { it.copy(showHomeCardMetadata = show) } + } } - suspend fun setHomeCache(cache: HomeCache) { + suspend fun setHideWatchedMovies(hide: Boolean) { context.dataStore.edit { preferences -> - val encodedCache = Json.encodeToString(cache) - preferences[Keys.HOME_CACHE] = encodedCache - val activeUserId = preferences[Keys.USER_ID] - val activeServer = preferences[Keys.SERVER_URL] - val profiles = profilesFrom(preferences).map { profile -> - if (profile.userId == activeUserId && profile.serverUrl == activeServer) { - profile.copy(homeCacheJson = encodedCache) - } else { - profile - } - } - if (profiles.isNotEmpty()) { - preferences[Keys.PROFILES] = Json.encodeToString(profiles) + preferences[Keys.HIDE_WATCHED_MOVIES] = hide + updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) } + } + } + + suspend fun setHomeRowPreferences( + order: List, + pinned: Set, + hidden: Set, + ) { + fun encode(values: Iterable): String = values + .map(String::trim) + .filter { it.isNotEmpty() && '\n' !in it } + .distinct() + .joinToString("\n") + val encodedOrder = encode(order) + val encodedPinned = encode(pinned) + val encodedHidden = encode(hidden - pinned) + context.dataStore.edit { preferences -> + preferences[Keys.HOME_ROW_ORDER] = encodedOrder + preferences[Keys.HOME_PINNED_ROWS] = encodedPinned + preferences[Keys.HOME_HIDDEN_ROWS] = encodedHidden + updateActiveProfile(preferences) { + it.copy( + homeRowOrder = encodedOrder, + homePinnedRows = encodedPinned, + homeHiddenRows = encodedHidden, + ) } } } + suspend fun setWelcomeQuoteStyle(style: String) { + val selected = style.takeIf { it in setOf("neutral", "positive", "homicidal") } + ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE + context.dataStore.edit { preferences -> + preferences[Keys.WELCOME_QUOTE_STYLE] = selected + updateActiveProfile(preferences) { it.copy(welcomeQuoteStyle = selected) } + } + } + + /** + * Persists the launcher's rows so the next cold start can draw before the network + * answers. + * + * This used to encode the cache, then re-encode the entire profiles blob with a copy + * of that same JSON embedded in the matching profile — and Preferences DataStore + * rewrites and fsyncs the whole file on every edit, so each call wrote the cache + * twice over, plus a copy for every other profile that was already carrying one. It + * runs on every home refresh and again on every playback stop, which is exactly when + * the viewer is watching the launcher redraw. + * + * Now each profile's cache lives under its own key ([profileHomeCacheKey]) and the + * profiles blob carries none, so a refresh writes one value and the blob stays small. + */ + suspend fun setHomeCache(cache: HomeCache) { + val encodedCache = Json.encodeToString(cache) + // Most refreshes find nothing new — a scheduled poll, or a playback stop on a row + // that did not move. Skipping the write means skipping the whole file rewrite. + if (encodedCache == lastPersistedHomeCache) return + context.dataStore.edit { preferences -> + val profileKey = profileHomeCacheKey( + userId = preferences[Keys.USER_ID], + serverUrl = preferences[Keys.SERVER_URL], + ) + if (profileKey == null) { + // No active profile to key against; the flat slot is all there is. + preferences[Keys.HOME_CACHE] = encodedCache + } else { + // Exactly one copy. Writing the flat key as well put the largest value + // this store holds into the file twice, and DataStore rewrites and fsyncs + // the whole file on every edit — so each home refresh was paying double + // for a value nothing read twice. [activeHomeCache] reads this key first. + preferences[profileKey] = encodedCache + preferences.remove(Keys.HOME_CACHE) + } + } + lastPersistedHomeCache = encodedCache + } + + /** + * Where a given profile's home cache is stored. Null when there is no active profile + * to key it against, in which case only the flat [Keys.HOME_CACHE] is written. + */ + private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key? { + if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null + return stringPreferencesKey("home_cache::$userId@$serverUrl") + } + + /** + * The active profile's cached rows. The per-profile key is authoritative; the flat + * [Keys.HOME_CACHE] slot survives only for installs written before the split and for + * the case where there is no profile to key against. + */ + private fun activeHomeCache(preferences: Preferences): String? = + profileHomeCacheKey( + userId = preferences[Keys.USER_ID], + serverUrl = preferences[Keys.SERVER_URL], + )?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE] + suspend fun setForYouMinutes(minutes: Int) { val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0 context.dataStore.edit { preferences -> @@ -242,6 +430,21 @@ class SettingsStore(private val context: Context) { } } + /** + * Records that a profile has finished recommendation onboarding, so the next cold + * start can go straight to the launcher instead of waiting on the gateway to say so. + * Additive only — nothing here ever un-onboards a user, because the failure mode of a + * wrongly-cleared flag (a returning viewer shown the rating screen again) is worse + * than the failure mode of a stale one (a request the gateway answers anyway). + */ + suspend fun markOnboardingCompleted(userId: String) { + val trimmed = userId.trim() + if (trimmed.isEmpty()) return + context.dataStore.edit { preferences -> + preferences[Keys.ONBOARDED_USERS] = preferences[Keys.ONBOARDED_USERS].orEmpty() + trimmed + } + } + suspend fun markForYouOpened() { context.dataStore.edit { preferences -> preferences[Keys.HAS_OPENED_FOR_YOU] = true @@ -263,14 +466,56 @@ class SettingsStore(private val context: Context) { } } if (profiles.isNotEmpty()) { - preferences[Keys.PROFILES] = Json.encodeToString(profiles) + writeProfiles(preferences, profiles) } } - fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let { - runCatching { Json.decodeFromString(it) }.getOrNull() + /** + * The single writer for the profiles blob. Strips each profile's home cache out into + * its own key, so this blob stays small enough that the many settings toggles which + * rewrite it — every one of which rewrites the whole DataStore file — cost nothing + * proportional to the size of the library. + * + * The strip doubles as the migration: an install whose blob still embeds a cache has + * it moved across the first time anything rewrites the blob, so a profile switch + * still restores that profile's rows. + */ + private fun writeProfiles(preferences: MutablePreferences, profiles: List) { + val stripped = profiles.map { profile -> + profile.homeCacheJson?.takeIf { it.isNotBlank() }?.let { embedded -> + profileHomeCacheKey(profile.userId, profile.serverUrl)?.let { key -> + if (preferences[key] == null) preferences[key] = embedded + } + } + if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null) + } + preferences[Keys.PROFILES] = Json.encodeToString(stripped) } + /** + * The launcher's cached rows. Returns the copy decoded by [primeHomeCache] when the + * JSON has not changed since, so the common path is a reference comparison rather + * than a parse — see the note on [settingsFlow]. + */ + fun homeCache(settings: Settings): HomeCache? { + val json = settings.homeCacheJson ?: return null + decodedHomeCache?.let { (source, decoded) -> if (source == json) return decoded } + return decodeHomeCache(json).also { decodedHomeCache = json to it } + } + + private fun primeHomeCache(settings: Settings) { + val json = settings.homeCacheJson + if (json == null) { + decodedHomeCache = null + return + } + if (decodedHomeCache?.first == json) return + decodedHomeCache = json to decodeHomeCache(json) + } + + private fun decodeHomeCache(json: String): HomeCache? = + runCatching { Json.decodeFromString(json) }.getOrNull() + /** * Reads DataStore directly instead of taking the replayed flow value. This matters * immediately after an edit, when the replay slot may still contain the prior value. @@ -302,10 +547,21 @@ class SettingsStore(private val context: Context) { homeCacheJson = previous?.homeCacheJson, forYouMinutes = previous?.forYouMinutes ?: 0, hasOpenedForYou = previous?.hasOpenedForYou ?: false, + welcomeQuoteStyle = previous?.welcomeQuoteStyle + ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE, + homeSections = previous?.homeSections ?: Settings.DEFAULT_HOME_SECTIONS, + homeCardDensity = previous?.homeCardDensity ?: Settings.DEFAULT_HOME_CARD_DENSITY, + homeArtworkStyle = previous?.homeArtworkStyle + ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, + showHomeCardMetadata = previous?.showHomeCardMetadata ?: true, + hideWatchedMovies = previous?.hideWatchedMovies ?: false, + homeRowOrder = previous?.homeRowOrder.orEmpty(), + homePinnedRows = previous?.homePinnedRows.orEmpty(), + homeHiddenRows = previous?.homeHiddenRows.orEmpty(), ) profiles.removeAll { it.id == id } profiles.add(profile) - preferences[Keys.PROFILES] = Json.encodeToString(profiles) + writeProfiles(preferences, profiles) applyProfile(preferences, profile) } } @@ -315,12 +571,30 @@ class SettingsStore(private val context: Context) { val profiles = profilesFrom(preferences).toMutableList() if (profiles.none { it.id == profile.id }) { profiles.add(profile) - preferences[Keys.PROFILES] = Json.encodeToString(profiles) + writeProfiles(preferences, profiles) } applyProfile(preferences, profile) } } + /** Forgets a saved profile on this device. */ + suspend fun removeProfile(profileId: String) { + context.dataStore.edit { preferences -> + val profiles = profilesFrom(preferences) + val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit + writeProfiles(preferences, profiles.filterNot { it.id == profileId }) + // Forget the departing profile's cached rows too; nothing will read that key + // again and it is the largest single value this store holds. + profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove) + if ( + preferences[Keys.USER_ID] == removed.userId && + preferences[Keys.SERVER_URL] == removed.serverUrl + ) { + clearActiveSession(preferences) + } + } + } + suspend fun clearSession() { context.dataStore.edit { clearActiveSession(it) @@ -340,7 +614,8 @@ class SettingsStore(private val context: Context) { val remaining = profilesFrom(preferences).filterNot { it.userId == activeUserId && it.serverUrl == activeServer } - preferences[Keys.PROFILES] = Json.encodeToString(remaining) + writeProfiles(preferences, remaining) + profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove) clearActiveSession(preferences) } } @@ -352,8 +627,18 @@ class SettingsStore(private val context: Context) { preferences.remove(Keys.SERVER_ID) preferences.remove(Keys.LAST_BACKDROP_URL) preferences.remove(Keys.HOME_CACHE) + lastPersistedHomeCache = null preferences.remove(Keys.FOR_YOU_MINUTES) preferences.remove(Keys.HAS_OPENED_FOR_YOU) + preferences.remove(Keys.WELCOME_QUOTE_STYLE) + preferences.remove(Keys.HOME_SECTIONS) + preferences.remove(Keys.HOME_CARD_DENSITY) + preferences.remove(Keys.HOME_ARTWORK_STYLE) + preferences.remove(Keys.SHOW_HOME_CARD_METADATA) + preferences.remove(Keys.HIDE_WATCHED_MOVIES) + preferences.remove(Keys.HOME_ROW_ORDER) + preferences.remove(Keys.HOME_PINNED_ROWS) + preferences.remove(Keys.HOME_HIDDEN_ROWS) preferences.remove(Keys.USERNAME) } @@ -364,10 +649,36 @@ class SettingsStore(private val context: Context) { preferences[Keys.USERNAME] = profile.username if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID) else preferences[Keys.SERVER_ID] = profile.serverId - if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE) - else preferences[Keys.HOME_CACHE] = profile.homeCacheJson + // Prefer the profile's dedicated cache key; fall back to a copy embedded in the + // profiles blob, which is how installs predating the split stored it — and move + // that copy across, so the migration completes here too. + val profileKey = profileHomeCacheKey(profile.userId, profile.serverUrl) + val cached = profileKey?.let { preferences[it] } ?: profile.homeCacheJson + if (profileKey != null) { + if (preferences[profileKey] == null && !cached.isNullOrBlank()) { + preferences[profileKey] = cached + } + // Reads go through [activeHomeCache], which prefers the profile key; keeping a + // second copy in the flat slot only inflates the file. + preferences.remove(Keys.HOME_CACHE) + } else if (cached.isNullOrBlank()) { + preferences.remove(Keys.HOME_CACHE) + } else { + preferences[Keys.HOME_CACHE] = cached + } + // The active cache belongs to a different profile now. + lastPersistedHomeCache = cached preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou + preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle + preferences[Keys.HOME_SECTIONS] = profile.homeSections + preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity + preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle + preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata + preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies + preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder + preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows + preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows preferences.remove(Keys.LAST_BACKDROP_URL) } @@ -383,9 +694,21 @@ class SettingsStore(private val context: Context) { userId = userId, username = username, serverId = preferences[Keys.SERVER_ID], - homeCacheJson = preferences[Keys.HOME_CACHE], + homeCacheJson = activeHomeCache(preferences), forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false, + welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE] + ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE, + homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, + homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] + ?: Settings.DEFAULT_HOME_CARD_DENSITY, + homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE] + ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, + showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, + hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false, + homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), + homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), + homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(), ) } @@ -413,14 +736,24 @@ class SettingsStore(private val context: Context) { updateToken = preferences[Keys.UPDATE_TOKEN], showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true, autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true, + showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true, ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR, lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL], homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, - homeCacheJson = preferences[Keys.HOME_CACHE], + homeCacheJson = activeHomeCache(preferences), forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false, homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY, + homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE] + ?: Settings.DEFAULT_HOME_ARTWORK_STYLE, showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true, + hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false, + homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(), + homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(), + homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(), + welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE] + ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE, + onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), profiles = profiles, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt index d88d6c0..41a1bcb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt @@ -27,16 +27,31 @@ class RowAnalytics( private val lock = Any() private val buffer = ArrayList() private val impressed = HashSet() + private val impressedItems = HashSet() private var focusedRowId: String? = null private var focusedRowKind: String = "" private var focusStartedAt: Long = 0 /** Records that a row was drawn. Repeats are ignored until [reset]. */ - fun rowImpression(rowId: String, rowKind: String) { + fun rowImpression(rowId: String, rowKind: String, visibleItemIds: List = emptyList()) { synchronized(lock) { - if (!impressed.add(rowId)) return - add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp())) + if (impressed.add(rowId)) { + add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp())) + } + visibleItemIds.filter(String::isNotBlank).forEach { itemId -> + if (impressedItems.add("$rowId:$itemId")) { + add( + GatewayRowEvent( + rowId = rowId, + rowKind = rowKind, + event = EVENT_IMPRESSION, + itemId = itemId, + occurredAt = timestamp(), + ), + ) + } + } } } @@ -99,6 +114,7 @@ class RowAnalytics( synchronized(lock) { buffer.clear() impressed.clear() + impressedItems.clear() focusedRowId = null } } 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 24deb1a..2f730cd 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 @@ -1,5 +1,8 @@ package com.ponzischeme89.memby.data.model +import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities +import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -33,6 +36,7 @@ data class UserItemData( @SerialName("IsFavorite") val isFavorite: Boolean = false, @SerialName("Played") val played: Boolean = false, @SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0, + @SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null, ) @Serializable @@ -53,6 +57,11 @@ data class PlaybackInfoRequest( @SerialName("Id") val id: String, @SerialName("UserId") val userId: String, @SerialName("IsPlayback") val isPlayback: Boolean = true, + @SerialName("EnableDirectPlay") val enableDirectPlay: Boolean = true, + @SerialName("EnableDirectStream") val enableDirectStream: Boolean = true, + @SerialName("EnableTranscoding") val enableTranscoding: Boolean = true, + @SerialName("AllowVideoStreamCopy") val allowVideoStreamCopy: Boolean = true, + @SerialName("AllowAudioStreamCopy") val allowAudioStreamCopy: Boolean = true, @SerialName("StartTimeTicks") val startTimeTicks: Long = 0, @SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null, @SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null, @@ -66,9 +75,12 @@ data class DeviceProfile( @SerialName("SubtitleProfiles") val subtitleProfiles: List, @SerialName("DirectPlayProfiles") val directPlayProfiles: List, @SerialName("TranscodingProfiles") val transcodingProfiles: List, + @SerialName("CodecProfiles") val codecProfiles: List = emptyList(), ) { companion object { - fun embyAndroidTv() = DeviceProfile( + fun embyAndroidTv( + capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities, + ) = DeviceProfile( name = "Memby Android TV", subtitleProfiles = listOf( "srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g", @@ -77,23 +89,143 @@ data class DeviceProfile( ).map { SubtitleProfile(it, "Encode") }, directPlayProfiles = listOf( DirectPlayProfile( - container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi", - videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4", - audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm", + // The broad codec declaration is bounded by CodecProfiles below. + container = "mkv,mp4,m4v,mov,ts,mpegts", + videoCodec = directPlayVideoCodecs(capabilities), + audioCodec = "aac,mp3", ), ), transcodingProfiles = listOf( TranscodingProfile( container = "ts", - videoCodec = "h264", + // Permits Emby to remux a supported HEVC/H.264 video stream while + // converting only incompatible audio or subtitles. + videoCodec = directPlayVideoCodecs(capabilities), audioCodec = "aac", protocol = "hls", ), ), + codecProfiles = codecProfiles(capabilities), ) + + /** Compatibility for callers and older tests that only know the HEVC boolean. */ + fun embyAndroidTv(supportsHevc: Boolean): DeviceProfile = embyAndroidTv( + DevicePlaybackCapabilities( + hevc = VideoDecoderCapabilities( + supported = supportsHevc, + profiles = if (supportsHevc) setOf("main") else emptySet(), + ), + ), + ) + + private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String = + if (capabilities.hevc.supported) "h264,hevc" else "h264" + + private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List = + buildList { + addVideoProfiles("h264", capabilities.h264) + addVideoProfiles("hevc", capabilities.hevc) + } + + private fun MutableList.addVideoProfiles( + codec: String, + capability: VideoDecoderCapabilities, + ) { + if (!capability.supported) return + val allowedProfiles = when (codec) { + "h264" -> capability.profiles.mapNotNull { + when (it) { + "baseline" -> "baseline" + "constrained_baseline" -> "constrained baseline" + "main" -> "main" + "high" -> "high" + "high10" -> "high 10" + else -> null + } + } + else -> capability.profiles.mapNotNull { + when (it) { + "main" -> "main" + "main10" -> "main 10" + else -> null + } + } + } + if (allowedProfiles.isNotEmpty()) { + add( + CodecProfile( + codec = codec, + conditions = listOf( + ProfileCondition("EqualsAny", "VideoProfile", allowedProfiles.joinToString("|")), + ), + ), + ) + } + if (capability.mainLevel > 0) { + add( + CodecProfile( + codec = codec, + conditions = listOf( + ProfileCondition("LessThanEqual", "VideoLevel", capability.mainLevel.toString()), + ), + applyConditions = listOf( + ProfileCondition( + "EqualsAny", + "VideoProfile", + if (codec == "h264") "baseline|constrained baseline|main|high" else "main", + ), + ), + ), + ) + } + if (capability.tenBitLevel > 0) { + add( + CodecProfile( + codec = codec, + conditions = listOf( + ProfileCondition("LessThanEqual", "VideoLevel", capability.tenBitLevel.toString()), + ), + applyConditions = listOf( + ProfileCondition( + "Equals", + "VideoProfile", + if (codec == "h264") "high 10" else "main 10", + ), + ), + ), + ) + } + if (capability.maxWidth > 0 && capability.maxHeight > 0) { + add( + CodecProfile( + codec = codec, + conditions = listOf( + ProfileCondition("LessThanEqual", "Width", capability.maxWidth.toString()), + ProfileCondition("LessThanEqual", "Height", capability.maxHeight.toString()), + ), + ), + ) + } + } } } +@Serializable +data class CodecProfile( + @SerialName("Type") val type: String = "Video", + @SerialName("Codec") val codec: String, + @SerialName("Conditions") val conditions: List, + @SerialName("ApplyConditions") val applyConditions: List = emptyList(), +) + +@Serializable +data class ProfileCondition( + @SerialName("Condition") val condition: String, + @SerialName("Property") val property: String, + @SerialName("Value") val value: String, + @SerialName("IsRequired") val isRequired: Boolean = false, +) + @Serializable data class DirectPlayProfile( @SerialName("Container") val container: String, @@ -156,6 +288,9 @@ data class PlaybackInfo( data class MediaSourceInfo( @SerialName("Id") val id: String = "", @SerialName("MediaStreams") val mediaStreams: List = emptyList(), + @SerialName("SupportsDirectPlay") val supportsDirectPlay: Boolean? = null, + @SerialName("SupportsDirectStream") val supportsDirectStream: Boolean? = null, + @SerialName("SupportsTranscoding") val supportsTranscoding: Boolean? = null, @SerialName("DirectStreamUrl") val directStreamUrl: String? = null, @SerialName("TranscodingUrl") val transcodingUrl: String? = null, ) @@ -183,7 +318,9 @@ data class BaseItem( @SerialName("CommunityRating") val communityRating: Double? = null, @SerialName("Studios") val studios: List = emptyList(), @SerialName("RunTimeTicks") val runTimeTicks: Long? = null, + @SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null, @SerialName("Genres") val genres: List = emptyList(), + @SerialName("CollectionName") val collectionName: String? = null, @SerialName("MediaStreams") val mediaStreams: List = emptyList(), @SerialName("People") val people: List = emptyList(), @SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null, @@ -205,21 +342,32 @@ data class BaseItem( @SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null, @SerialName("MembyAirsAt") val membyAirsAt: String? = null, @SerialName("MembyAddedAt") val membyAddedAt: String? = null, + @SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null, @SerialName("MembyAirLabel") val membyAirLabel: String? = null, @SerialName("MembyAvailability") val membyAvailability: String? = null, @SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null, @SerialName("MembyPlayable") val membyPlayable: Boolean = true, - // Derived by the TV from the Sonarr schedule row and retained in the local home cache. + // Derived by the TV from the weekly schedule row and retained in the local home cache. @SerialName("MembyAiringToday") val membyAiringToday: Boolean = false, // Explainability supplied only by the gateway's dedicated For You endpoint. @SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null, @SerialName("MembyCompatibility") val membyCompatibility: String? = null, + // Backend diagnostics for the shared explainable ranker. These remain optional so + // direct-to-Emby mode and older cached payloads decode unchanged. + @SerialName("MembyRecommendationScore") val membyRecommendationScore: Double? = null, + @SerialName("MembyRecommendationComponents") + val membyRecommendationComponents: Map = emptyMap(), + @SerialName("MembyRecommendationReasonCodes") + val membyRecommendationReasonCodes: List = emptyList(), + @SerialName("MembyExploration") val membyExploration: Boolean = false, ) { val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true) val isSeries: Boolean get() = type.equals("Series", ignoreCase = true) val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true) val isFavorite: Boolean get() = userData?.isFavorite == true - val isSonarrSchedule: Boolean get() = membySource == "sonarr" + val isTvSchedule: Boolean get() = membySource == "sonarr" + val isMovieSchedule: Boolean get() = membySource == "radarr" + val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule val cast: List get() = people.filter(EmbyPerson::isCastMember) /** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */ 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 f35c9b2..981d6fa 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 @@ -24,23 +24,25 @@ data class GatewayLoginResponse( val userId: String = "", val username: String = "", val serverId: String = "", - val activeClients: Int = 0, - val maxClientsPerUser: Int = 0, ) @Serializable -data class GatewayAuthPolicy( - val maxClientsPerUser: Int = 0, +data class GatewayDevice( + val deviceId: String, + val deviceName: String, + val clientVersion: String = "", + val lastSeenAt: String = "", + val current: Boolean = false, ) @Serializable -data class GatewayAuthError( - val error: String = "", - val message: String = "", - val activeClients: Int = 0, - val maxClientsPerUser: Int = 0, +data class GatewayDevices( + val devices: List = emptyList(), ) +@Serializable +data class GatewayDeviceNameRequest(val deviceName: String) + /** * One horizontal strip, described entirely by the server. * @@ -76,8 +78,8 @@ data class GatewayHome( /** * The gateway's verdict on this build, from `GET /v1/update`. * - * [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the - * operator has decided this version may no longer be used. + * [status] is `none`, `optional` or `mandatory`. Mandatory blocks the app before login or + * home is composed — the operator has decided this version may no longer be used. */ @Serializable data class GatewayUpdate( @@ -85,6 +87,8 @@ data class GatewayUpdate( val version: String = "", val notes: String = "", val downloadUrl: String = "", + val sha256: String = "", + val sizeBytes: Long = 0, ) { val isMandatory: Boolean get() = status == STATUS_MANDATORY val isOptional: Boolean get() = status == STATUS_OPTIONAL @@ -110,18 +114,47 @@ data class GatewayServiceStatus( val clientVersion: String = "", val clientProtocol: String = "", val serverProtocol: Int = 0, + val featureSchemaVersion: Int = 0, + val featureRevision: Long = 0, + val safeMode: Boolean = false, + val features: Map = emptyMap(), +) + +@Serializable +data class GatewayFeature( + val key: String, + val name: String = "", + val description: String = "", + val area: String = "", + val enabled: Boolean = false, + val source: String = "default", + val compatible: Boolean = true, + val minimumProtocol: Int = 0, + val capability: String = "", + val recovery: String = "", +) + +@Serializable +data class GatewayFeatures( + val schemaVersion: Int = 0, + val revision: Long = 0, + val safeMode: Boolean = false, + val canRollback: Boolean = false, + val features: List = emptyList(), ) /** - * An informational nudge riding along on the status poll — currently "this episode aired - * and is on its way into Emby". It is never actionable: the banner slides in, states the - * news and leaves. Unknown [kind] values still render, so the server can add one without - * an app release. + * An informational nudge riding along on the status poll — "this episode aired and is on + * its way into Emby", or "Radarr just imported this film". It is never actionable: the + * banner slides in, states the news and leaves. Unknown [kind] values still render, and + * the server supplies the wording, so a new kind of news needs no app release. */ @Serializable data class GatewayAlert( val id: String = "", val kind: String = "", + /** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */ + val label: String = "", val title: String = "", val message: String = "", val itemId: String = "", @@ -135,16 +168,70 @@ data class GatewayRows( val rows: List = emptyList(), ) +@Serializable +data class RecommendationOnboarding( + val completed: Boolean = false, + val ratings: Map = emptyMap(), + val items: List = emptyList(), +) + +@Serializable +data class RecommendationPreferences( + val ratings: Map = emptyMap(), +) + @Serializable data class GatewayItems( val items: List = emptyList(), ) +/** + * Response of `GET /v1/items/{id}/related` — the detail page's two additions. + * + * [reasons] are short phrases from the recommendation engine explaining *this viewer's* + * relationship to the title ("Because you watch Thriller"); [items] is the carousel of + * what else is like it. Either half may be empty: a cold profile has no reasons to give, + * and an obscure title has nothing beside it. + */ +@Serializable +data class GatewayRelated( + val reasons: List = emptyList(), + val items: List = emptyList(), +) + @Serializable data class GatewaySearchHistory( val queries: List = emptyList(), ) +@Serializable +data class GatewayRequestCandidate( + val mediaType: String, + val foreignId: Int, + val title: String, + val year: Int = 0, + val overview: String = "", + val posterUrl: String = "", + val alreadyAdded: Boolean = false, +) + +@Serializable +data class GatewayRequestLookup( + val candidates: List = emptyList(), +) + +@Serializable +data class GatewayMediaRequest( + val mediaType: String, + val foreignId: Int, +) + +@Serializable +data class GatewayMediaRequestResult( + val status: String = "", + val title: String = "", +) + @Serializable data class GatewayPrerollSchedule( val today: List = emptyList(), @@ -153,6 +240,8 @@ data class GatewayPrerollSchedule( @Serializable data class GatewayPrerollEntry( + val itemId: String = "", + val imageType: String = "", val series: String = "", val episode: String = "", val episodeCode: String = "", @@ -164,6 +253,12 @@ data class GatewayPrerollEntry( data class GatewayPlayback( val itemId: String, val title: String = "", + val overview: String = "", + val seriesName: String = "", + val episodeCode: String = "", + val runtimeMs: Long = 0, + val prerollEnabled: Boolean = true, + val prerollDurationMs: Long = 6_500L, val url: String, val resumePositionMs: Long = 0, val subtitles: List = emptyList(), @@ -188,11 +283,72 @@ data class GatewayNextEpisode( val playMethod: String = "DirectPlay", ) +@Serializable +data class GatewaySeasonFinale( + val seasonFinale: Boolean = false, + val seriesName: String = "", + val seasonNumber: Int = 0, + val episodeNumber: Int = 0, +) + @Serializable data class GatewayFlagRequest( val value: Boolean, ) +@Serializable +data class MyShow( + val itemId: String, + val title: String, + val year: Int? = null, + val imageTag: String = "", + val addedAt: String = "", + val sonarrStatus: String = "Not found", + val nextEpisode: String? = null, + val lifecycle: String = "Unknown", + val monitored: Boolean = false, +) + +@Serializable +data class MyShowsResponse( + val shows: List = emptyList(), +) + +@Serializable +data class SaveMyShowRequest( + val itemId: String, + val title: String, + val year: Int? = null, + val imageTag: String = "", +) + +@Serializable +data class NotificationPreferences( + val enabled: Boolean = true, + val showReturnAlerts: Boolean = true, + val leadDays: Int = 7, +) + +@Serializable +data class UserNotification( + val id: Long, + val kind: String = "", + val itemId: String = "", + val title: String = "", + val message: String = "", + val eventAt: String? = null, + val createdAt: String = "", + val readAt: String? = null, +) { + val unread: Boolean get() = readAt.isNullOrBlank() +} + +@Serializable +data class NotificationsResponse( + val notifications: List = emptyList(), + val preferences: NotificationPreferences = NotificationPreferences(), +) + /** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */ @Serializable data class GatewayRowEvent( @@ -213,9 +369,15 @@ data class GatewayRowEvents( data class GatewayPlaybackReport( val itemId: String, val positionMs: Long, + val durationMs: Long = 0, val isPaused: Boolean = false, val mediaSourceId: String = "", val playSessionId: String = "", val playMethod: String = "DirectPlay", val eventName: String? = null, ) + +@Serializable +data class GatewayPlaybackReportResponse( + val autoFollowedShowTitle: String = "", +) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/playback/DevicePlaybackCapabilities.kt b/app/src/main/java/com/ponzischeme89/memby/data/playback/DevicePlaybackCapabilities.kt new file mode 100644 index 0000000..9c74518 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/playback/DevicePlaybackCapabilities.kt @@ -0,0 +1,224 @@ +/* + * Playback capability probing adapted from Wholphin's + * MediaCodecCapabilitiesTest.kt, which is itself derived from Jellyfin Android TV. + * + * Wholphin: https://github.com/damontecres/Wholphin + * Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv + * + * Modifications Copyright (C) 2026 Memby contributors + * SPDX-License-Identifier: GPL-2.0-only + */ + +package com.ponzischeme89.memby.data.playback + +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build + +data class VideoDecoderCapabilities( + val supported: Boolean = false, + val profiles: Set = emptySet(), + val mainLevel: Int = 0, + val tenBitLevel: Int = 0, + val maxWidth: Int = 0, + val maxHeight: Int = 0, + val hdr10: Boolean = false, + val hdr10Plus: Boolean = false, + val dolbyVision: Boolean = false, +) + +data class DevicePlaybackCapabilities( + val h264: VideoDecoderCapabilities = VideoDecoderCapabilities(supported = true), + val hevc: VideoDecoderCapabilities = VideoDecoderCapabilities(), +) + +/** + * The expensive platform query is performed once, off the UI thread on first network or + * direct-play negotiation. A single immutable result then describes this installed build + * to both the Memby gateway and Emby itself. + */ +val devicePlaybackCapabilities: DevicePlaybackCapabilities by lazy { + runCatching { AndroidVideoCapabilityProbe().probe() } + // H.264 is Android's baseline playback format and remains the safe fallback when + // a vendor codec exposes incomplete or broken MediaCodec metadata. + .getOrElse { DevicePlaybackCapabilities() } +} + +internal fun DevicePlaybackCapabilities.gatewayCapabilityTokens(): List = buildList { + if (h264.supported) add("video_h264_decode") + h264.profiles.sorted().forEach { add("video_h264_profile_$it") } + if (h264.mainLevel > 0) add("video_h264_level_${h264.mainLevel}") + if (h264.tenBitLevel > 0) add("video_h264_high10_level_${h264.tenBitLevel}") + addResolution("video_h264", h264) + + if (hevc.supported) add("video_hevc_decode") + hevc.profiles.sorted().forEach { add("video_hevc_profile_$it") } + if (hevc.mainLevel > 0) add("video_hevc_main_level_${hevc.mainLevel}") + if (hevc.tenBitLevel > 0) add("video_hevc_main10_level_${hevc.tenBitLevel}") + addResolution("video_hevc", hevc) + if (hevc.hdr10) add("video_hevc_hdr10") + if (hevc.hdr10Plus) add("video_hevc_hdr10plus") + if (hevc.dolbyVision) add("video_hevc_dolby_vision") +} + +private fun MutableList.addResolution(prefix: String, codec: VideoDecoderCapabilities) { + if (codec.maxWidth > 0 && codec.maxHeight > 0) { + add("${prefix}_max_${codec.maxWidth}x${codec.maxHeight}") + } +} + +private class AndroidVideoCapabilityProbe { + private val codecInfos by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } + + fun probe(): DevicePlaybackCapabilities = DevicePlaybackCapabilities( + h264 = probeH264(), + hevc = probeHevc(), + ) + + private fun probeH264(): VideoDecoderCapabilities { + val mime = MediaFormat.MIMETYPE_VIDEO_AVC + val supported = hasCodecForMime(mime) + // As in Wholphin, ordinary AVC support covers the backwards-compatible + // baseline/main/high family even when vendor metadata lists only its highest + // profile. High10 remains opt-in because it is not universally supported. + val high10Level = mappedLevel( + mime, + setOf(CodecProfileLevel.AVCProfileHigh10), + AVC_LEVELS, + ) + val profiles = buildSet { + if (supported) addAll(listOf("baseline", "constrained_baseline", "main", "high")) + if (high10Level > 0) add("high10") + } + val (maxWidth, maxHeight) = maxResolution(mime) + return VideoDecoderCapabilities( + supported = supported, + profiles = profiles, + mainLevel = mappedLevel( + mime, + setOf( + CodecProfileLevel.AVCProfileBaseline, + CodecProfileLevel.AVCProfileMain, + CodecProfileLevel.AVCProfileHigh, + ), + AVC_LEVELS, + ), + tenBitLevel = high10Level, + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + } + + private fun probeHevc(): VideoDecoderCapabilities { + val mime = MediaFormat.MIMETYPE_VIDEO_HEVC + val supported = hasCodecForMime(mime) + val mainLevel = mappedLevel( + mime, + setOf(CodecProfileLevel.HEVCProfileMain), + HEVC_LEVELS, + ) + val main10Level = mappedLevel( + mime, + setOf(CodecProfileLevel.HEVCProfileMain10), + HEVC_LEVELS, + ) + val (maxWidth, maxHeight) = maxResolution(mime) + return VideoDecoderCapabilities( + supported = supported, + profiles = buildSet { + if (supported) add("main") + if (main10Level > 0) add("main10") + }, + mainLevel = mainLevel, + tenBitLevel = main10Level, + maxWidth = maxWidth, + maxHeight = maxHeight, + hdr10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10), + hdr10Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10Plus), + dolbyVision = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION), + ) + } + + private fun hasCodecForMime(mime: String): Boolean = codecInfos.any { info -> + !info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) } + } + + private fun hasProfile(mime: String, profile: Int): Boolean = + maxPlatformLevel(mime, setOf(profile)) > 0 + + private fun mappedLevel( + mime: String, + profiles: Set, + levels: List>, + ): Int { + val platformLevel = maxPlatformLevel(mime, profiles) + return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0 + } + + private fun maxPlatformLevel(mime: String, profiles: Set): Int { + var maximum = 0 + codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info -> + runCatching { info.getCapabilitiesForType(mime) }.getOrNull() + ?.profileLevels + ?.filter { it.profile in profiles } + ?.forEach { maximum = maxOf(maximum, it.level) } + } + return maximum + } + + private fun maxResolution(mime: String): Pair { + var maxWidth = 0 + var maxHeight = 0 + codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info -> + val video = runCatching { info.getCapabilitiesForType(mime).videoCapabilities }.getOrNull() + ?: return@forEach + maxWidth = maxOf(maxWidth, video.supportedWidths?.upper ?: 0) + maxHeight = maxOf(maxHeight, video.supportedHeights?.upper ?: 0) + } + return maxWidth to maxHeight + } + + companion object { + // ffprobe/Emby represent AVC levels multiplied by 10 (4.1 -> 41). + private val AVC_LEVELS = listOf( + CodecProfileLevel.AVCLevel1b to 9, + CodecProfileLevel.AVCLevel1 to 10, + CodecProfileLevel.AVCLevel11 to 11, + CodecProfileLevel.AVCLevel12 to 12, + CodecProfileLevel.AVCLevel13 to 13, + CodecProfileLevel.AVCLevel2 to 20, + CodecProfileLevel.AVCLevel21 to 21, + CodecProfileLevel.AVCLevel22 to 22, + CodecProfileLevel.AVCLevel3 to 30, + CodecProfileLevel.AVCLevel31 to 31, + CodecProfileLevel.AVCLevel32 to 32, + CodecProfileLevel.AVCLevel4 to 40, + CodecProfileLevel.AVCLevel41 to 41, + CodecProfileLevel.AVCLevel42 to 42, + CodecProfileLevel.AVCLevel5 to 50, + CodecProfileLevel.AVCLevel51 to 51, + CodecProfileLevel.AVCLevel52 to 52, + ) + + // ffprobe/Emby represent HEVC levels multiplied by 30 (4.1 -> 123). + private val HEVC_LEVELS = listOf( + CodecProfileLevel.HEVCMainTierLevel1 to 30, + CodecProfileLevel.HEVCMainTierLevel2 to 60, + CodecProfileLevel.HEVCMainTierLevel21 to 63, + CodecProfileLevel.HEVCMainTierLevel3 to 90, + CodecProfileLevel.HEVCMainTierLevel31 to 93, + CodecProfileLevel.HEVCMainTierLevel4 to 120, + CodecProfileLevel.HEVCMainTierLevel41 to 123, + CodecProfileLevel.HEVCMainTierLevel5 to 150, + CodecProfileLevel.HEVCMainTierLevel51 to 153, + CodecProfileLevel.HEVCMainTierLevel52 to 156, + CodecProfileLevel.HEVCMainTierLevel6 to 180, + CodecProfileLevel.HEVCMainTierLevel61 to 183, + CodecProfileLevel.HEVCMainTierLevel62 to 186, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt index 58cfa85..783c5de 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt @@ -53,6 +53,16 @@ interface EmbyApi { @Path("itemId") itemId: String, ): ItemsResult + /** + * Emby's own similarity ranking. The direct path has no recommendation engine behind + * it, so this is the whole of "More like this" when the gateway is not in play. + */ + @GET("Items/{itemId}/Similar") + suspend fun getSimilar( + @Path("itemId") itemId: String, + @QueryMap params: Map, + ): ItemsResult + @GET("Shows/NextUp") suspend fun getNextUp(@QueryMap params: Map): ItemsResult diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt index e00c904..733566d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt @@ -5,7 +5,6 @@ import com.ponzischeme89.memby.BuildConfig import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -36,7 +35,9 @@ object EmbyServiceFactory { redactHeader("X-Emby-Authorization") } - val client = OkHttpClient.Builder() + // Derived from the shared stack, so this keeps the one connection pool and + // dispatcher the artwork loader also uses. See HttpStack. + val client = HttpStack.base.newBuilder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider)) 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 1c4f463..128b8f2 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 @@ -2,11 +2,15 @@ package com.ponzischeme89.memby.data.remote import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayFlagRequest -import com.ponzischeme89.memby.data.model.GatewayAuthPolicy +import com.ponzischeme89.memby.data.model.GatewayFeatures +import com.ponzischeme89.memby.data.model.GatewayDevices +import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest import com.ponzischeme89.memby.data.model.GatewayHome import com.ponzischeme89.memby.data.model.GatewayItems import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayLoginResponse +import com.ponzischeme89.memby.data.model.GatewayMediaRequest +import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult import com.ponzischeme89.memby.data.model.GatewayNextEpisode import com.ponzischeme89.memby.data.model.GatewayPlayback import com.ponzischeme89.memby.data.model.GatewayPlaybackReport @@ -14,12 +18,17 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRows import com.ponzischeme89.memby.data.model.GatewaySearchHistory +import com.ponzischeme89.memby.data.model.GatewayRequestLookup import com.ponzischeme89.memby.data.model.GatewayServiceStatus import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.data.model.RecommendationOnboarding +import com.ponzischeme89.memby.data.model.RecommendationPreferences import com.ponzischeme89.memby.data.model.UserItemData import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.DELETE import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -35,9 +44,6 @@ interface GatewayApi { @POST("v1/auth/login") suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse - @GET("v1/auth/policy") - suspend fun authPolicy(): GatewayAuthPolicy - @POST("v1/auth/logout") suspend fun logout() @@ -45,6 +51,18 @@ interface GatewayApi { @GET("v1/auth/session") suspend fun session(): GatewayLoginResponse + @GET("v1/auth/devices") + suspend fun devices(): GatewayDevices + + @DELETE("v1/auth/devices/{deviceId}") + suspend fun removeDevice(@Path("deviceId") deviceId: String) + + @PUT("v1/auth/devices/{deviceId}") + suspend fun renameDevice( + @Path("deviceId") deviceId: String, + @Body body: GatewayDeviceNameRequest, + ) + @GET("v1/home") suspend fun home(@Query("limit") limit: Int): GatewayHome @@ -60,6 +78,12 @@ interface GatewayApi { @GET("v1/search/history") suspend fun recentSearches(): GatewaySearchHistory + @GET("v1/requests/lookup") + suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup + + @POST("v1/requests") + suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult + /** Recommendation rows on their own. `/v1/home` already embeds these when warm. */ @GET("v1/recommendations") suspend fun recommendations(): GatewayRows @@ -67,6 +91,12 @@ interface GatewayApi { @GET("v1/for-you") suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows + @GET("v1/recommendations/preferences") + suspend fun recommendationPreferences(): RecommendationOnboarding + + @PUT("v1/recommendations/preferences") + suspend fun saveRecommendationPreferences(@Body body: RecommendationPreferences) + @GET("v1/preroll") suspend fun prerollSchedule(): GatewayPrerollSchedule @@ -77,17 +107,57 @@ interface GatewayApi { @GET("v1/update") suspend fun updateStatus(): GatewayUpdate + @GET("v1/my-shows") + suspend fun myShows(): com.ponzischeme89.memby.data.model.MyShowsResponse + + @POST("v1/my-shows") + suspend fun saveMyShow( + @Body body: com.ponzischeme89.memby.data.model.SaveMyShowRequest, + ): com.ponzischeme89.memby.data.model.MyShowsResponse + + @DELETE("v1/my-shows/{id}") + suspend fun removeMyShow(@Path("id") itemId: String) + + @GET("v1/notifications") + suspend fun notifications(): com.ponzischeme89.memby.data.model.NotificationsResponse + + @PUT("v1/notifications") + suspend fun setNotificationPreferences( + @Body body: com.ponzischeme89.memby.data.model.NotificationPreferences, + ): com.ponzischeme89.memby.data.model.NotificationsResponse + + @POST("v1/notifications/{id}/{action}") + suspend fun updateNotification( + @Path("id") id: Long, + @Path("action") action: String, + ) + /** Available during maintenance so an open app can be interrupted immediately. */ @GET("v1/status") suspend fun serviceStatus(): GatewayServiceStatus + /** Versioned server control-plane document; unknown flags remain safely ignorable. */ + @GET("v1/features") + suspend fun features(): GatewayFeatures + @GET("v1/items/{id}") suspend fun item(@Path("id") itemId: String): BaseItem + @GET("v1/items/{id}/season-finale") + suspend fun seasonFinale( + @Path("id") itemId: String, + ): com.ponzischeme89.memby.data.model.GatewaySeasonFinale + /** All episodes for a series in display order; the client groups them into seasons. */ @GET("v1/items/{id}/episodes") suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems + /** Why this viewer might enjoy the item, and what else in the library is like it. */ + @GET("v1/items/{id}/related") + suspend fun related( + @Path("id") itemId: String, + ): com.ponzischeme89.memby.data.model.GatewayRelated + @GET("v1/items/{id}/playback") suspend fun playback( @Path("id") itemId: String, @@ -95,6 +165,7 @@ interface GatewayApi { @Query("title") title: String, @Query("resumePositionMs") resumePositionMs: Long, @Query("subtitleIndex") subtitleIndex: Int? = null, + @Query("forceTranscode") forceTranscode: Boolean = false, ): GatewayPlayback /** 404 when nothing follows this item: a movie, or a series finale. */ @@ -114,7 +185,10 @@ interface GatewayApi { suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData @POST("v1/playback/{phase}") - suspend fun report(@Path("phase") phase: String, @Body body: GatewayPlaybackReport) + suspend fun report( + @Path("phase") phase: String, + @Body body: GatewayPlaybackReport, + ): com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse /** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */ @POST("v1/analytics/rows") 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 57c6bea..0b0632f 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 @@ -2,10 +2,11 @@ package com.ponzischeme89.memby.data.remote import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.ponzischeme89.memby.BuildConfig +import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient import okhttp3.Response import retrofit2.Retrofit import java.util.concurrent.TimeUnit @@ -23,7 +24,9 @@ object GatewayServiceFactory { fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi { val contentType = "application/json".toMediaType() - val client = OkHttpClient.Builder() + // Derived from the shared stack: artwork in gateway mode is proxied by this very + // host, so the poster fetches reuse the connection this client established. + val client = HttpStack.base.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) // The gateway answers home from Redis in single-digit milliseconds; a long // read timeout here only ever means Emby itself is struggling behind it. @@ -52,6 +55,11 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : // says which build it is. .header("X-Memby-Version", BuildConfig.VERSION_NAME) .header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString()) + .header( + "X-Memby-Capabilities", + (MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens()) + .joinToString(","), + ) tokenProvider()?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") } @@ -60,3 +68,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : } internal const val MEMBY_PROTOCOL_VERSION = 1 + +internal val MEMBY_CAPABILITIES = listOf( + "server_features_v1", + "live_feature_refresh_v1", + "sonarr_preroll_v1", + "auto_my_shows_v1", +) + +internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode" diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/HttpStack.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/HttpStack.kt new file mode 100644 index 0000000..a8e23bd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/HttpStack.kt @@ -0,0 +1,53 @@ +package com.ponzischeme89.memby.data.remote + +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +/** + * The one HTTP stack in the app. Everything that talks to the network — the Emby API, the + * gateway API and Coil's artwork loader — is built from [base], so they share a single + * connection pool, dispatcher and thread pool. + * + * Sharing matters most for artwork. In gateway mode the images are proxied by the same + * HTTPS host that serves `/v1/home`, so a poster fetched on a client of its own would + * open a fresh TCP connection and repeat the TLS handshake that the home request had just + * finished — perhaps a couple of hundred milliseconds per cold image over a domestic + * connection, multiplied by the fifteen-odd posters a launcher paints at once. Reusing + * the pool makes those fetches resume an established connection instead. + * + * Timeouts and auth headers still differ per caller, so each factory calls + * [base].newBuilder() and adds its own. That is the intended way to specialise an OkHttp + * client: the derived client keeps the shared pool and dispatcher. + */ +internal object HttpStack { + + /** + * A launcher paints far more than the default five concurrent requests per host, and + * in gateway mode every one of them is the same host. Five means posters arrive in + * visible waves; this lets a screenful start together while staying well short of + * what would swamp a TV's radio. + */ + private const val MAX_REQUESTS_PER_HOST = 15 + + private val connectionPool = ConnectionPool( + maxIdleConnections = 8, + keepAliveDuration = 5, + timeUnit = TimeUnit.MINUTES, + ) + + private val dispatcher = Dispatcher().apply { + maxRequests = 32 + maxRequestsPerHost = MAX_REQUESTS_PER_HOST + } + + val base: OkHttpClient = OkHttpClient.Builder() + .connectionPool(connectionPool) + .dispatcher(dispatcher) + // Sensible floor; every caller overrides these to suit what it is fetching. + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt new file mode 100644 index 0000000..5700c26 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -0,0 +1,719 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.IntrinsicSize +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.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +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.Dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.detail.DetailTab +import com.ponzischeme89.memby.ui.detail.DetailZone +import com.ponzischeme89.memby.ui.detail.TechnicalSpec +import com.ponzischeme89.memby.ui.detail.formatRuntime +import com.ponzischeme89.memby.ui.detail.ratingLabel +import com.ponzischeme89.memby.ui.theme.FactSeparator +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.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyPanelCorner +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembyScore +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.ValueSeparator +import kotlinx.coroutines.launch + +// The detail page's names for the shared tokens. The neutrals used to be a shade darker +// here than on the launcher, which is visible the moment a page opens from a row. +internal val DetailBackground = MembySurface +internal val DetailAccent = MembyAccent +internal val DetailText = MembyOnSurface +internal val DetailMutedText = MembyMutedText +internal val DetailQuietText = MembyQuietText +internal val DetailHairline = MembyHairline +internal val DetailSideGutter = 58.dp +private val DetailTabHeight = 66.dp + +/** + * How much of the content pane is left showing under the tab strip. + * + * It does two jobs. The strip used to be anchored to the very bottom of the screen, where a + * TV's overscan ate the selection underline and part of the labels — this is the safe-area + * inset the rest of the app already keeps. And because what fills the gap is the top of the + * pane rather than more background, it is the one thing on screen saying that Down from the + * strip reveals something. + */ +private val DetailFoldPeek = 34.dp + +/** + * The height one tab's pane gets, from the viewport it has to fit inside. + * + * It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio — + * fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left + * of the screen once the pane has been scrolled to its resting position under the strip. + */ +internal fun detailPaneHeight(viewportHeight: Dp): Dp = + (viewportHeight - 132.dp).coerceIn(250.dp, 420.dp) + +internal data class DetailHeroAction( + val icon: ImageVector, + val description: String, + val active: Boolean = false, + val label: String? = null, + val onClick: () -> Unit, +) + +/** Full-bleed artwork with a protected reading area on the left and at the fold. */ +@Composable +internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) { + val repository = ServiceLocator.repository + val artwork = remember(item.id, item.backdropImageTags, item.imageTags) { + repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280) + } + Box(modifier.background(DetailBackground)) { + if (artwork != null) { + AsyncImage( + model = artwork, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xF2080A0C), + 0.42f to Color(0xCC080A0C), + 0.72f to Color(0x38080A0C), + 1f to Color(0x10080A0C), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0f to Color(0x18000000), + 0.48f to Color(0x26080A0C), + 0.78f to Color(0xE6080A0C), + 1f to DetailBackground, + ), + ), + ) + } +} + +/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */ +@Composable +internal fun DetailPageScaffold( + item: BaseItem, + facts: List, + badges: List, + tabs: List, + selectedTab: DetailTab, + onSelectTab: (DetailTab) -> Unit, + playLabel: String, + onPlay: () -> Unit, + playFocusRequester: FocusRequester, + tabFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + modifier: Modifier = Modifier, + progress: Float = 0f, + progressLabel: String? = null, + reasons: List = emptyList(), + heroActions: List = emptyList(), + confirmation: String? = null, + pageListState: LazyListState = remember(item.id) { LazyListState() }, + onZoneFocused: (DetailZone) -> Unit = {}, + content: @Composable BoxScope.(DetailTab) -> Unit, +) { + val scope = rememberCoroutineScope() + // Keep the same requesters when an async trailer action appears. Replacing the list + // while the viewer is already on Favourites would detach the focused node. + val allActionRequesters = remember(item.id) { + List(6) { FocusRequester() } + } + val actionRequesters = allActionRequesters.take(heroActions.size) + var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } + var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } + val heroReturn = if (lastHeroIndex in actionRequesters.indices) { + actionRequesters[lastHeroIndex] + } else { + playFocusRequester + } + fun reveal(index: Int, offset: Int = 0) { + scope.launch { pageListState.animateScrollToItem(index, offset) } + } + fun revealHero() { + scope.launch { + // LazyColumn also scrolls a newly focused descendant into view. That request + // can land after onFocusChanged and used to win over reveal(0), leaving Play + // focused with the logo and metadata above the viewport. Snap once now and + // once after focus relocation has completed so hero focus always means the + // complete opening frame. + pageListState.scrollToItem(0) + withFrameNanos { } + pageListState.scrollToItem(0) + } + } + // Focus relocation belongs to LazyColumn and may run after the focus callback. Keep + // the opening frame pinned for as long as focus remains in the hero, regardless of + // which relocation wins a particular frame. Moving to Tabs or Content releases it. + androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) { + if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect + snapshotFlow { + pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if (index != 0 || offset != 0) pageListState.scrollToItem(0) + } + } + + BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) { + // The opening composition is one deliberate TV frame: hero above, tabs anchored + // to its bottom edge. Content begins below the fold and only enters when the + // viewer presses Down from the tabs. + val heroHeight = (maxHeight - DetailTabHeight - DetailFoldPeek).coerceAtLeast(340.dp) + val paneHeight = detailPaneHeight(maxHeight) + LazyColumn( + state = pageListState, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "hero") { + DetailHero( + item = item, + facts = facts, + badges = badges, + playLabel = playLabel, + onPlay = onPlay, + playFocusRequester = playFocusRequester, + tabFocusRequester = tabFocusRequester, + progress = progress, + progressLabel = progressLabel, + reasons = reasons, + actions = heroActions, + actionRequesters = actionRequesters, + height = heroHeight, + onActionFocused = { index -> + lastHeroIndex = index + focusedZone = DetailZone.PLAY + onZoneFocused(DetailZone.PLAY) + revealHero() + }, + onPlayFocused = { + lastHeroIndex = -1 + focusedZone = DetailZone.PLAY + onZoneFocused(DetailZone.PLAY) + revealHero() + }, + ) + } + item(key = "tabs") { + DetailTabStrip( + tabs = tabs, + selected = selectedTab, + onSelect = onSelectTab, + selectedFocusRequester = tabFocusRequester, + heroFocusRequester = heroReturn, + contentFocusRequester = contentFocusRequester, + onFocused = { + focusedZone = DetailZone.TABS + onZoneFocused(DetailZone.TABS) + reveal(1, -18) + }, + ) + } + item(key = "content") { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) }, + label = "detail-tab-content", + modifier = Modifier + .fillMaxWidth() + .padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp) + .height(paneHeight) + .onFocusChanged { + if (it.hasFocus) { + focusedZone = DetailZone.CONTENT + onZoneFocused(DetailZone.CONTENT) + reveal(2, -72) + } + } + .focusProperties { up = tabFocusRequester }, + ) { visibleTab -> + Box(Modifier.fillMaxSize()) { content(visibleTab) } + } + } + } + + confirmation?.let { + Text( + text = it, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + .shadow(16.dp, RoundedCornerShape(MembyPanelCorner)) + .clip(RoundedCornerShape(MembyPanelCorner)) + .background(Color(0xEE20252A)) + .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner)) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) + } + } +} + +/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */ +internal fun detailHeroScrollTarget(zone: DetailZone): Int? = + if (zone == DetailZone.PLAY) 0 else null + +@Composable +private fun DetailHero( + item: BaseItem, + facts: List, + badges: List, + playLabel: String, + onPlay: () -> Unit, + playFocusRequester: FocusRequester, + tabFocusRequester: FocusRequester, + progress: Float, + progressLabel: String?, + reasons: List, + actions: List, + actionRequesters: List, + height: Dp, + onPlayFocused: () -> Unit, + onActionFocused: (Int) -> Unit, +) { + // Settings promises the logo preference applies to titles generally; only the + // screensaver honoured it. The dark-logo fallback comes with it — a black title + // treatment on this near-black scrim is an invisible heading. + val repository = ServiceLocator.repository + val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) { + if (repository.showTitleLogo) repository.logoUrl(item, 720) else null + } + val logo = logoUrl.takeIf { !useTextTitleForLogo(it) } + Box(Modifier.fillMaxWidth().height(height)) { + DetailBackdrop(item, Modifier.fillMaxSize()) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp) + .fillMaxWidth(0.58f), + ) { + if (logo != null) { + AsyncImage( + model = logo, + contentDescription = item.name, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + modifier = Modifier.width(330.dp).height(92.dp), + ) + } else { + Text( + text = item.name, + color = Color.White, + fontSize = 38.sp, + lineHeight = 42.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(10.dp)) + DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item)) + if (item.genres.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + text = item.genres.take(4).joinToString(ValueSeparator), + color = DetailMutedText, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(10.dp)) + Text( + text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.", + color = DetailText, + fontSize = 15.sp, + lineHeight = 20.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + if (progress > 0f) { + Spacer(Modifier.height(12.dp)) + DetailProgress(progress, progressLabel) + } + if (reasons.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + Text( + text = reasons.first(), + color = DetailAccent, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(16.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester }, + ) { + MembyPlayButton( + label = playLabel, + onClick = onPlay, + onFocused = onPlayFocused, + modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester), + ) + actions.forEachIndexed { index, action -> + DetailCircularAction( + action = action, + onFocused = { onActionFocused(index) }, + modifier = Modifier.focusRequester(actionRequesters[index]), + ) + } + } + } + } +} + +@Composable +private fun DetailCircularAction( + action: DetailHeroAction, + onFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus") + Row( + modifier = modifier + .then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp)) + .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } + .shadow(if (focused) 16.dp else 5.dp, CircleShape) + .clip(CircleShape) + .background(if (action.active) DetailAccent else Color(0xB3262B30)) + .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape) + .semantics { contentDescription = action.description } + .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() } + .clickable(onClick = action.onClick) + .padding(horizontal = if (action.label == null) 0.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp)) + action.label?.let { label -> + Spacer(Modifier.width(8.dp)) + Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1) + } + } +} + +@Composable +internal fun DetailFactRow( + facts: List, + badges: List = emptyList(), + rating: String? = null, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val values = buildList { + addAll(facts) + rating?.let { add("★ $it") } + } + values.forEachIndexed { index, value -> + if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp) + Text(value, color = if (value.startsWith("★")) MembyScore else DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + // Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped + // the airing badge appended after them, which is the one that is news. + badges.take(4).forEach { badge -> + Spacer(Modifier.width(8.dp)) + MediaBadge(badge) + } + } +} + +@Composable +private fun DetailTabStrip( + tabs: List, + selected: DetailTab, + onSelect: (DetailTab) -> Unit, + selectedFocusRequester: FocusRequester, + heroFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + onFocused: () -> Unit, +) { + Box( + Modifier + .fillMaxWidth() + .height(DetailTabHeight) + .background(DetailBackground) + .padding(horizontal = DetailSideGutter), + ) { + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline)) + Row( + modifier = Modifier + .fillMaxSize() + .focusGroup() + .focusProperties { up = heroFocusRequester; down = contentFocusRequester }, + horizontalArrangement = Arrangement.spacedBy(34.dp), + verticalAlignment = Alignment.Bottom, + ) { + tabs.forEach { tab -> + var focused by remember(tab) { mutableStateOf(false) } + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .then(if (tab == selected) Modifier.focusRequester(selectedFocusRequester) else Modifier) + .testTag("detail-tab-${tab.key}") + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) { onSelect(tab); onFocused() } + } + .clickable { onSelect(tab) }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + tab.label, + color = if (selected == tab || focused) Color.White else DetailQuietText, + fontSize = 15.sp, + fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp), + ) + Box( + Modifier.fillMaxWidth().height(3.dp).background( + if (selected == tab || focused) DetailAccent else Color.Transparent, + ), + ) + } + } + Spacer(Modifier.weight(1f)) + // The tab's content begins below the fold by design. Nothing said so; the + // peek under this strip and this chevron are what say it. Never focusable — + // it is a caption on the Down key, not another thing to land on. + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = DetailQuietText, + modifier = Modifier.size(18.dp).padding(bottom = 2.dp), + ) + } + } +} + +@Composable +internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) { + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) { + Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent)) + } + label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) } + } +} + +@Composable +internal fun DetailFocusablePane( + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier + .fillMaxSize() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused } + .focusable() + .clip(RoundedCornerShape(MembyCardCorner)) + .border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner)) + .background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent) + .padding(16.dp), + ) { content() } +} + +@Composable +internal fun DetailMetaRows(rows: List, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) { + rows.forEach { row -> + Row(Modifier.fillMaxWidth()) { + Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth)) + Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +internal fun DetailOverviewPane( + item: BaseItem, + credits: List, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, + supportingText: String? = null, +) { + DetailFocusablePane(focusRequester, modifier) { + Column { + Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis) + supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) } + Spacer(Modifier.height(16.dp)) + DetailMetaRows(credits.take(4)) + } + } +} + +@Composable +internal fun DetailCastAndDetailsPane( + item: BaseItem, + credits: List, + specs: List, + detailsLoaded: Boolean, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val releaseAndTechnical = remember(item.id, specs, credits) { + val alreadyCredited = credits.map(TechnicalSpec::label).toSet() + buildList { + item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) } + item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) } + item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) } + // Studio is in both vocabularies. It only became visible as a duplicate once + // the pane stopped clipping its own second half. + addAll(specs.filterNot { it.label in alreadyCredited }) + } + } + DetailFocusablePane(focusRequester, modifier) { + if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) { + Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp) + } else { + Column { + if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) { + DetailMetaRows(credits, Modifier.weight(1f)) + DetailMetaRows(releaseAndTechnical, Modifier.weight(1f)) + } + } + } + } +} + +@Composable +internal fun DetailMoreLikeThisPane( + items: List, + loading: Boolean, + onSelect: (BaseItem) -> Unit, + firstFocusRequester: FocusRequester, + listState: LazyListState, + modifier: Modifier = Modifier, +) { + when { + loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) } + items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) } + else -> LazyRow( + state = listState, + modifier = modifier.fillMaxSize().focusGroup(), + horizontalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp), + ) { + itemsIndexed(items, key = { _, it -> it.id }) { index, related -> + DetailPosterCard( + item = related, + onClick = { onSelect(related) }, + modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, + ) + } + } + } +} + +@Composable +private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) { + val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) } + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus") + Column( + modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick), + ) { + Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) { + if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } + Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp)) + } +} + +@Composable +internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) } +} 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 1278777..df2d381 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -24,12 +25,15 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -61,6 +65,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -75,6 +83,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CheckCircle @@ -83,9 +93,11 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder 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.Person +import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.material.icons.filled.Recommend import androidx.compose.material.icons.filled.Search @@ -95,26 +107,42 @@ import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.VisibilityOff import androidx.tv.material3.Icon -import androidx.tv.material3.Button import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.R 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.detail.UHD_MIN_WIDTH +import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel +import com.ponzischeme89.memby.ui.detail.formatRuntime +import com.ponzischeme89.memby.ui.detail.ratingLabel +import com.ponzischeme89.memby.ui.theme.FactSeparator +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyPanelCorner +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembyScore +import com.ponzischeme89.memby.ui.theme.ValueSeparator import java.util.Locale +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -private val EmbyGreen = Color(0xFF52B54B) +// The launcher's names for the shared tokens. Both surfaces read from one palette now +// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side, +// so a green or a grey that differs by a shade differs in front of the viewer. +private val EmbyGreen = MembyAccent private val RailSurface = Color(0xF20C0F12) -// High-contrast neutrals tuned for the app's near-black surfaces. Quiet remains visibly -// secondary from TV distance without falling into low-contrast grey-on-black. -private val MutedText = Color(0xFFD0D6DB) -private val QuietText = Color(0xFFAEB7BF) +private val MutedText = MembyMutedText +private val QuietText = MembyQuietText internal val TvRailCollapsedWidth = 54.dp internal val TvRailExpandedWidth = 184.dp internal val TvRailContentShift = 112.dp @@ -140,6 +168,7 @@ data class HomeBrowseRow( val loading: Boolean = false, val emptyMessage: String, val showSecondaryMetadata: Boolean = true, + val showWatchedEpisodeCount: Boolean = false, ) private data class HomeRowVisual( @@ -323,6 +352,241 @@ fun TvNavigationRail( } } +@Composable +fun UserSwitcherOverlay( + profiles: List, + activeProfileId: String?, + onProfileSelected: (EmbyProfile) -> Unit, + onManageProfiles: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val profileIds = profiles.map(EmbyProfile::id) + val focusRequesters = remember(profileIds) { + List(profiles.size + 1) { FocusRequester() } + } + val profileListState = remember(profileIds) { LazyListState() } + val focusScope = rememberCoroutineScope() + var focusedIndex by remember(profileIds) { + mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId)) + } + LaunchedEffect(profileIds, activeProfileId) { + focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId) + if (profiles.isNotEmpty()) { + profileListState.scrollToItem(focusedIndex) + } + // Let a lazily composed off-screen profile attach its FocusRequester first. + delay(16) + runCatching { focusRequesters[focusedIndex].requestFocus() } + } + + Box( + modifier + .fillMaxSize() + .zIndex(20f) + .background(Color.Black.copy(alpha = 0.56f)), + ) { + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 52.dp) + .width(292.dp) + .heightIn(max = 400.dp) + .shadow(12.dp, RoundedCornerShape(MembyPanelCorner)) + .clip(RoundedCornerShape(MembyPanelCorner)) + .background(Color(0xFF090B0D)) + .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner)) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionUp, Key.DirectionDown -> { + val direction = if (event.key == Key.DirectionUp) { + UserSwitcherDirection.UP + } else { + UserSwitcherDirection.DOWN + } + val next = userSwitcherNextIndex( + currentIndex = focusedIndex, + profileCount = profiles.size, + direction = direction, + ) + focusedIndex = next + if (next < profiles.size) { + focusScope.launch { + profileListState.scrollToItem(next) + delay(16) + runCatching { focusRequesters[next].requestFocus() } + } + } else { + runCatching { focusRequesters[next].requestFocus() } + } + true + } + Key.DirectionLeft, Key.Back -> { + onDismiss() + true + } + // Keep focus inside this modal instead of allowing spatial focus + // search to land on a dimmed card behind it. + Key.DirectionRight -> true + else -> false + } + } + .padding(10.dp), + ) { + Text( + "Switch user", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp), + ) + Text( + "Choose who’s watching", + color = QuietText, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp), + ) + LazyColumn( + state = profileListState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 248.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed( + items = profiles, + key = { _, profile -> profile.id }, + ) { index, profile -> + UserSwitcherProfileItem( + profile = profile, + current = profile.id == activeProfileId, + modifier = Modifier + .focusRequester(focusRequesters[index]) + .onFocusChanged { if (it.isFocused) focusedIndex = index }, + onClick = { onProfileSelected(profile) }, + ) + } + } + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.07f)), + ) + Spacer(Modifier.height(6.dp)) + UserSwitcherAction( + label = "Manage users", + modifier = Modifier + .focusRequester(focusRequesters[profiles.size]) + .onFocusChanged { + if (it.isFocused) focusedIndex = profiles.size + }, + onClick = onManageProfiles, + ) + } + } +} + +@Composable +private fun UserSwitcherProfileItem( + profile: EmbyProfile, + current: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .onFocusChanged { focused = it.isFocused } + .clip(RoundedCornerShape(MembyChipCorner)) + .background( + when { + focused -> Color.White.copy(alpha = 0.11f) + current -> EmbyGreen.copy(alpha = 0.07f) + else -> Color.Transparent + }, + ) + .clickable(onClick = onClick) + .semantics { + contentDescription = + if (current) "${profile.username}, current user" else profile.username + } + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(26.dp) + .background(if (current) EmbyGreen else Color(0xFF2B3035), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + profile.username.trim().firstOrNull()?.uppercase() ?: "?", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ) + } + Text( + profile.username, + color = if (focused || current) Color.White else MutedText, + fontSize = 14.sp, + fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (current) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = EmbyGreen, + modifier = Modifier.size(14.dp), + ) + } + } +} + +@Composable +private fun UserSwitcherAction( + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .height(40.dp) + .onFocusChanged { focused = it.isFocused } + .clip(RoundedCornerShape(MembyChipCorner)) + .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Settings, + contentDescription = null, + tint = if (focused) Color.White else QuietText, + modifier = Modifier.size(17.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + label, + color = if (focused) Color.White else QuietText, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + @Composable fun ExpandableNavigationItem( destination: BrowseDestination, @@ -452,50 +716,54 @@ fun MediaMetadataPanel( item: BaseItem?, loading: Boolean, sectionLabel: String, - navigationFocusRequester: FocusRequester, - onPlay: (BaseItem) -> Unit, - onContentFocused: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier) { - Box(Modifier.weight(1f)) { - when { - item == null && loading -> MetadataLoadingSkeleton() - item == null -> { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(sectionLabel, color = QuietText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Text("Your library", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.SemiBold) - Text("Choose something to watch.", color = MutedText, fontSize = 16.sp) + BoxWithConstraints(modifier) { + val compactLayout = maxHeight < 260.dp + val contentWidth = metadataPanelContentWidth(maxWidth, compactLayout) + Column(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().fillMaxWidth()) { + when { + item == null && loading -> MetadataLoadingSkeleton(contentWidth) + item == null -> { + Column( + modifier = Modifier.width(contentWidth), + verticalArrangement = Arrangement.spacedBy(if (compactLayout) 7.dp else 10.dp), + ) { + Text(sectionLabel, color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text( + "Your library", + color = Color.White, + fontSize = if (compactLayout) 25.sp else 29.sp, + fontWeight = FontWeight.SemiBold, + ) + Text("Choose something to watch.", color = MutedText, fontSize = 14.sp) + } } + else -> MetadataContent(item, sectionLabel, contentWidth, compactLayout) } - else -> MetadataContent(item, sectionLabel) } } - Button( - onClick = { item?.let(onPlay) }, - enabled = item != null, - modifier = Modifier - .focusProperties { left = navigationFocusRequester } - .onFocusChanged { if (it.isFocused) onContentFocused() }, - ) { - val resumable = (item?.userData?.playbackPositionTicks ?: 0L) > 0L - Text(if (resumable) "▶ Resume" else "▶ Play") - } } } +internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp = + (availableWidth * if (compact) 0.84f else 0.76f) + .coerceIn(280.dp, 720.dp) + .coerceAtMost(availableWidth) + @Composable -private fun MetadataLoadingSkeleton() { +private fun MetadataLoadingSkeleton(contentWidth: Dp) { Column( - modifier = Modifier.fillMaxWidth(0.68f), + modifier = Modifier.width(contentWidth), verticalArrangement = Arrangement.spacedBy(11.dp), ) { - SkeletonBlock(82.dp, 11.dp) - SkeletonBlock(360.dp, 30.dp) - SkeletonBlock(270.dp, 14.dp) + SkeletonBlock(contentWidth * 0.18f, 11.dp) + SkeletonBlock(contentWidth * 0.62f, 30.dp) + SkeletonBlock(contentWidth * 0.46f, 14.dp) Spacer(Modifier.height(2.dp)) - SkeletonBlock(520.dp, 13.dp) - SkeletonBlock(470.dp, 13.dp) + SkeletonBlock(contentWidth, 13.dp) + SkeletonBlock(contentWidth * 0.86f, 13.dp) } } @@ -510,213 +778,309 @@ private fun SkeletonBlock(width: Dp, height: Dp) { ) } -@Composable -fun MediaDetailsOverlay( - item: BaseItem, - onPlay: (BaseItem) -> Unit, - onToggleFavorite: (BaseItem, Boolean) -> Unit, - onTogglePlayed: (BaseItem, Boolean) -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier, -) { - val firstAction = remember { FocusRequester() } - LaunchedEffect(item.id) { firstAction.requestFocus() } - - Box( - modifier - .fillMaxSize() - .background(Color(0xFF090B0D)), - ) { - BackdropLayer(item, Modifier.fillMaxSize()) - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.22f)), - ) - Column( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.68f) - .padding(start = 72.dp, end = 36.dp, top = 38.dp, bottom = 32.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = when { - item.isMovie -> "MOVIE" - item.isSeries -> "SERIES" - item.isEpisode -> "EPISODE" - else -> item.type.uppercase() - }, - color = EmbyGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.3.sp, - ) - Spacer(Modifier.height(10.dp)) - Text( - item.name, - color = Color.White, - fontSize = 38.sp, - lineHeight = 42.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(10.dp)) - val facts = listOfNotNull( - item.productionYear?.toString(), - item.runtimeMinutes?.let(::formatTvRuntime), - item.officialRating, - item.communityRating?.let { "★ ${String.format(Locale.US, "%.1f", it)}" }, - item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank), - ) - Text( - facts.joinToString(" • "), - color = MutedText, - fontSize = 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (item.membyAiringToday) { - Spacer(Modifier.height(8.dp)) - MediaBadge("AIRING TODAY") - } - Spacer(Modifier.height(16.dp)) - Text( - item.overview?.takeIf(String::isNotBlank) ?: "No description available.", - color = Color(0xFFD8DCDF), - fontSize = 17.sp, - lineHeight = 23.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - if (item.cast.isNotEmpty()) { - Spacer(Modifier.height(14.dp)) - CastRail(item.cast, compact = true) - } - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { onPlay(item) }, - modifier = Modifier.focusRequester(firstAction), - ) { - val resumable = (item.userData?.playbackPositionTicks ?: 0L) > 0L - Text(if (item.isSeries) "▶ Play next episode" else if (resumable) "▶ Resume" else "▶ Play") - } - Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) { - Text(if (item.isFavorite) "Remove favourite" else "Add favourite") - } - Button(onClick = { onTogglePlayed(item, item.userData?.played != true) }) { - Text(if (item.userData?.played == true) "Mark unwatched" else "Mark watched") - } - Button(onClick = onClose) { Text("Close") } - } - } - } -} - @Composable fun MediaQuickActionsOverlay( item: BaseItem, + onOpenDetails: (BaseItem) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, + rowTitle: String? = null, + rowPinned: Boolean = false, + onToggleRowPinned: (() -> Unit)? = null, + onHideRow: (() -> Unit)? = null, + onMoveRow: ((Int) -> Unit)? = null, onClose: () -> Unit, ) { - val firstAction = remember { FocusRequester() } - LaunchedEffect(item.id) { firstAction.requestFocus() } + val hasRowActions = rowTitle != null && + onToggleRowPinned != null && + onHideRow != null && + onMoveRow != null + val actionCount = if (hasRowActions) 8 else 4 + val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } } + var focusedIndex by remember(item.id) { mutableStateOf(0) } + // The menu can appear while OK is still physically held. Until that opening press + // is released, consume all activation events so it cannot trigger the first action. + var openingPressReleased by remember(item.id) { mutableStateOf(false) } + LaunchedEffect(item.id) { + delay(16) + runCatching { focusRequesters.first().requestFocus() } + } Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), - contentAlignment = Alignment.Center, + .zIndex(20f) + .background(Color.Black.copy(alpha = 0.58f)), ) { Column( modifier = Modifier - .width(520.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color(0xFF171B1F)) - .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(14.dp)) - .padding(28.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .width(352.dp) + .shadow(12.dp, RoundedCornerShape(MembyPanelCorner)) + .clip(RoundedCornerShape(MembyPanelCorner)) + .background(Color(0xFF090B0D)) + .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner)) + .onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + val activationKey = + native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_ENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER + if (activationKey && !openingPressReleased) { + if (native.action == android.view.KeyEvent.ACTION_UP) { + openingPressReleased = true + } + return@onPreviewKeyEvent true + } + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + when (event.key) { + Key.DirectionUp, Key.DirectionDown -> { + val direction = if (event.key == Key.DirectionUp) { + QuickActionDirection.UP + } else { + QuickActionDirection.DOWN + } + focusedIndex = quickActionNextIndex( + currentIndex = focusedIndex, + actionCount = actionCount, + direction = direction, + ) + runCatching { focusRequesters[focusedIndex].requestFocus() } + true + } + Key.DirectionLeft, Key.Back -> { + onClose() + true + } + Key.DirectionRight -> true + else -> false + } + } + .padding(10.dp), ) { - Text("Quick actions", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text( + "Quick actions", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp), + ) Text( item.name, - color = Color.White, - fontSize = 25.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + color = QuietText, + fontSize = 12.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp), ) - Spacer(Modifier.height(4.dp)) - Button( + QuickActionMenuItem( + label = "View details", + icon = Icons.Default.Info, + modifier = Modifier + .focusRequester(focusRequesters[0]) + .onFocusChanged { if (it.isFocused) focusedIndex = 0 }, + onClick = { onOpenDetails(item) }, + ) + Spacer(Modifier.height(2.dp)) + QuickActionMenuItem( + label = if (item.isFavorite) "Remove from favourites" else "Add to favourites", + icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + modifier = Modifier + .focusRequester(focusRequesters[1]) + .onFocusChanged { if (it.isFocused) focusedIndex = 1 }, onClick = { onSetFavorite(item, !item.isFavorite) onClose() }, - modifier = Modifier.focusRequester(firstAction), - ) { - Text(if (item.isFavorite) "Clear from favourites" else "Add to favourites") - } - Button( + ) + Spacer(Modifier.height(2.dp)) + QuickActionMenuItem( + label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", + icon = Icons.Default.CheckCircle, + modifier = Modifier + .focusRequester(focusRequesters[2]) + .onFocusChanged { if (it.isFocused) focusedIndex = 2 }, onClick = { onSetPlayed(item, item.userData?.played != true) onClose() }, - ) { - Text(if (item.userData?.played == true) "Mark unwatched" else "Mark watched") + ) + if (hasRowActions) { + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.07f)), + ) + Text( + rowTitle.orEmpty(), + color = QuietText, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp), + ) + QuickActionMenuItem( + label = if (rowPinned) "Unpin row" else "Pin row to top", + icon = Icons.Default.PushPin, + modifier = Modifier + .focusRequester(focusRequesters[3]) + .onFocusChanged { if (it.isFocused) focusedIndex = 3 }, + onClick = onToggleRowPinned!!, + ) + QuickActionMenuItem( + label = "Move row up", + icon = Icons.Default.ArrowUpward, + modifier = Modifier + .focusRequester(focusRequesters[4]) + .onFocusChanged { if (it.isFocused) focusedIndex = 4 }, + onClick = { onMoveRow!!(-1) }, + ) + QuickActionMenuItem( + label = "Move row down", + icon = Icons.Default.ArrowDownward, + modifier = Modifier + .focusRequester(focusRequesters[5]) + .onFocusChanged { if (it.isFocused) focusedIndex = 5 }, + onClick = { onMoveRow!!(1) }, + ) + QuickActionMenuItem( + label = "Hide this row", + icon = Icons.Default.VisibilityOff, + modifier = Modifier + .focusRequester(focusRequesters[6]) + .onFocusChanged { if (it.isFocused) focusedIndex = 6 }, + onClick = onHideRow!!, + ) } - Button(onClick = onClose) { Text("Cancel") } + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.07f)), + ) + Spacer(Modifier.height(6.dp)) + QuickActionMenuItem( + label = "Close", + icon = Icons.Default.ChevronLeft, + modifier = Modifier + .focusRequester(focusRequesters[actionCount - 1]) + .onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 }, + onClick = onClose, + ) } } } @Composable -private fun MetadataContent(item: BaseItem, sectionLabel: String) { - if (item.isSonarrSchedule) { - ScheduleMetadataContent(item, sectionLabel) +private fun QuickActionMenuItem( + label: String, + icon: ImageVector, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .height(42.dp) + .onFocusChanged { focused = it.isFocused } + .clip(RoundedCornerShape(MembyChipCorner)) + .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (focused) Color.White else QuietText, + modifier = Modifier.size(17.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + label, + color = if (focused) Color.White else MutedText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun MetadataContent( + item: BaseItem, + sectionLabel: String, + contentWidth: Dp, + compact: Boolean, +) { + if (item.isSchedule) { + ScheduleMetadataContent(item, sectionLabel, contentWidth, compact) return } Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.width(contentWidth).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), ) { - Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp) + Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp) Text( item.seriesName?.takeIf { item.isEpisode } ?: item.name, color = Color.White, - fontSize = 30.sp, - lineHeight = 34.sp, + fontSize = if (compact) 25.sp else 29.sp, + lineHeight = if (compact) 28.sp else 32.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (item.isEpisode && item.seriesName != null) { - Text(item.name, color = MutedText, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } val facts = buildList { item.productionYear?.let { add(it.toString()) } - item.runtimeMinutes?.let { add(formatTvRuntime(it)) } + item.runtimeMinutes?.let { add(formatRuntime(it)) } item.officialRating?.takeIf(String::isNotBlank)?.let(::add) - item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") } - item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(" · ")) } + item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) } } - if (facts.isNotEmpty()) { - Text(facts.joinToString(" • "), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + // The score is its own run of text so it can carry the same gold as the detail + // page. Joined into the grey fact line it was the one rating in the app that + // changed colour depending on which screen you were looking at. + val score = ratingLabel(item) + if (facts.isNotEmpty() || score != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (facts.isNotEmpty()) { + Text( + facts.joinToString(FactSeparator), + color = MutedText, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + score?.let { + if (facts.isNotEmpty()) Text(FactSeparator, color = QuietText, fontSize = 13.sp) + Text("★ $it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + } } val badges = buildList { - if (item.membyAiringToday) add("AIRING TODAY") + airingBadgeLabel(item)?.let(::add) addAll(mediaBadges(item)) } item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let { Text( text = it, color = EmbyGreen, - fontSize = 14.sp, + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(0.78f), + modifier = Modifier.fillMaxWidth(), ) } if (badges.isNotEmpty()) { @@ -727,48 +1091,67 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) { Text( item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = Color(0xFFD0D4D7), - fontSize = 15.sp, - lineHeight = 20.sp, - maxLines = 3, + fontSize = 14.sp, + lineHeight = 18.sp, + maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(0.74f), + modifier = Modifier.fillMaxWidth(), ) MetadataStatus(item) } } @Composable -private fun ScheduleMetadataContent(item: BaseItem, sectionLabel: String) { +private fun ScheduleMetadataContent( + item: BaseItem, + sectionLabel: String, + contentWidth: Dp, + compact: Boolean, +) { Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(7.dp), + modifier = Modifier.width(contentWidth).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), ) { Text( sectionLabel.uppercase(), color = EmbyGreen, - fontSize = 12.sp, + fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, ) Text( item.name, color = Color.White, - fontSize = 30.sp, - lineHeight = 34.sp, + fontSize = if (compact) 25.sp else 29.sp, + lineHeight = if (compact) 28.sp else 32.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - listOfNotNull( - item.membyEpisodeCode, - item.membyEpisodeTitle?.takeIf(String::isNotBlank), - ).joinToString(" • "), - color = MutedText, - fontSize = 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (item.isTvSchedule) { + Text( + listOfNotNull( + item.membyEpisodeCode, + item.membyEpisodeTitle?.takeIf(String::isNotBlank), + ).joinToString(FactSeparator), + color = MutedText, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + listOfNotNull( + item.productionYear?.toString(), + item.runtimeMinutes?.let(::formatRuntime), + item.genres.take(2).joinToString(ValueSeparator).takeIf(String::isNotBlank), + ).joinToString(FactSeparator), + color = MutedText, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) } item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let { @@ -777,16 +1160,20 @@ private fun ScheduleMetadataContent(item: BaseItem, sectionLabel: String) { } Text( item.overview?.takeIf(String::isNotBlank) - ?: "Episode information will appear when Sonarr receives it.", + ?: if (item.isMovieSchedule) { + "Movie details will appear when they become available." + } else { + "Episode details will appear when they become available." + }, color = Color(0xFFD0D4D7), - fontSize = 15.sp, - lineHeight = 20.sp, - maxLines = 3, + fontSize = 14.sp, + lineHeight = 18.sp, + maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(0.74f), + modifier = Modifier.fillMaxWidth(), ) Text( - "Schedule information from Sonarr", + if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week", color = QuietText, fontSize = 12.sp, ) @@ -821,6 +1208,37 @@ private fun MetadataStatus(item: BaseItem) { } } +/** + * The 28dp icon chip every home row header leads with, and the gap after it. + * + * Extracted because a header that skipped the chip started its title 38dp further left than + * the rows above and below it. The launcher's row titles are read as one column, and one + * that steps sideways breaks the column. + */ +internal val HomeRowHeaderIconGap = 10.dp + +/** Vertical gap between a row's header and its cards. One value for every row. */ +internal val HomeRowHeaderSpacing = 6.dp + +@Composable +internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(28.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.08f)) + .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(15.dp), + ) + } +} + @Composable fun MediaBadge(label: String, modifier: Modifier = Modifier) { Text( @@ -839,12 +1257,11 @@ internal fun mediaBadges(item: BaseItem): List { val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) } val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) } return buildList { - if ((video?.width ?: 0) >= 3_800) add("4K") - val range = listOfNotNull(video?.videoRange, video?.videoRangeType, video?.title).joinToString(" ").lowercase() - when { - "dolby vision" in range || "dovi" in range -> add("DOLBY VISION") - "hdr" in range -> add("HDR") - } + if ((video?.width ?: 0) >= UHD_MIN_WIDTH) add("4K") + // Same reading of the streams as the detail page's spec row, so a file cannot be + // HDR10+ in one place and plain HDR in the other. + dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title) + ?.let { add(it.uppercase(Locale.US)) } if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS") }.distinct() @@ -852,51 +1269,68 @@ internal fun mediaBadges(item: BaseItem): List { @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable -fun MediaRow( +internal fun MediaRow( row: HomeBrowseRow, availableWidth: Dp, navigationFocusRequester: FocusRequester, contentEntryFocusRequester: FocusRequester?, returnFocusItemId: String?, returnFocusRequester: FocusRequester, + verticalFocusRequest: RowFocusRequest?, + onVerticalFocusRequestConsumed: (Int) -> Unit, + onMoveVertical: (itemIndex: Int, direction: RowFocusDirection) -> Boolean, onContentFocused: () -> Unit, onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, onItemLongPressed: (BaseItem) -> Unit, + density: String = "standard", + artworkStyle: String = "automatic", + horizontalState: LazyListState? = null, modifier: Modifier = Modifier, ) { - val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } + val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } + val rowState = horizontalState ?: savedRowState + val verticalEntryFocusRequester = remember { FocusRequester() } val scope = rememberCoroutineScope() + val requestedEntryIndex = verticalFocusRequest + ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } + ?.itemIndex + ?.coerceIn(0, row.items.lastIndex) + LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) { + val request = verticalFocusRequest + ?.takeIf { it.rowId == row.id && requestedEntryIndex != null } + ?: return@LaunchedEffect + rowState.scrollToItem(requestedEntryIndex!!) + // LazyRow applies scrollToItem during layout. Wait for the requested card's focus + // node to attach before transferring focus; retrying covers slower TV frames. + repeat(3) { + kotlinx.coroutines.delay(16L) + if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { + onVerticalFocusRequestConsumed(request.requestId) + return@LaunchedEffect + } + } + onVerticalFocusRequestConsumed(request.requestId) + } // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so // only the button's enabled/disabled flip recomposes the row header. val canScrollBack by remember(rowState) { derivedStateOf { rowState.firstVisibleItemIndex > 0 } } + val canScrollForward by remember(rowState) { + derivedStateOf { rowState.canScrollForward } + } val pageSize = if ( - row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT + row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT ) 6 else 4 - Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically, ) { val visual = homeRowVisual(row) - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.08f)) - .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = visual.icon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(15.dp), - ) - } - Spacer(Modifier.width(10.dp)) + HomeRowHeaderIcon(visual.icon) + Spacer(Modifier.width(HomeRowHeaderIconGap)) Text( row.title, color = Color(0xFFF1F3F4), @@ -916,7 +1350,7 @@ fun MediaRow( Spacer(Modifier.width(8.dp)) GalleryJumpButton( forward = true, - enabled = rowState.canScrollForward, + enabled = canScrollForward, onClick = { val target = (rowState.firstVisibleItemIndex + pageSize) .coerceAtMost(row.items.lastIndex) @@ -967,7 +1401,7 @@ fun MediaRow( itemsIndexed( row.items, key = { _, item -> item.id }, - contentType = { _, item -> if (cardFormat(row.kind, item) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" }, + contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" }, ) { index, item -> var cardModifier: Modifier = Modifier if (index == 0) { @@ -978,24 +1412,40 @@ fun MediaRow( if (item.id == returnFocusItemId) { cardModifier = cardModifier.focusRequester(returnFocusRequester) } + if (index == requestedEntryIndex) { + cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester) + } + cardModifier = cardModifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + when (event.key) { + Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP) + Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN) + else -> false + } + } val focused: () -> Unit = { onContentFocused() onItemFocused(item) } - when (cardFormat(row.kind, item)) { + when (cardFormat(row.kind, item, artworkStyle)) { MediaCardFormat.PORTRAIT -> PortraitMediaCard( item, availableWidth, row.showSecondaryMetadata, focused, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + density, row.showWatchedEpisodeCount, ) MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) { ContinueWatchingCard( item, availableWidth, row.showSecondaryMetadata, focused, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + density, row.showWatchedEpisodeCount, ) } else { LandscapeMediaCard( item, availableWidth, row.showSecondaryMetadata, focused, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + density, row.showWatchedEpisodeCount, ) } } @@ -1048,7 +1498,7 @@ private fun CastCard(person: EmbyPerson, compact: Boolean) { var focused by remember { mutableStateOf(false) } val width = if (compact) 72.dp else 112.dp val height = if (compact) 76.dp else 142.dp - val shape = RoundedCornerShape(if (compact) 8.dp else 10.dp) + val shape = RoundedCornerShape(MembyCardCorner) val scale by animateFloatAsState( targetValue = if (focused) 1.045f else 1f, animationSpec = tween(110), @@ -1124,12 +1574,12 @@ private fun FavoriteShowsEmptyState( modifier = Modifier .fillMaxWidth() .padding(horizontal = 36.dp, vertical = 10.dp) - .clip(RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(MembyPanelCorner)) .background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f)) .border( 2.dp, if (focused) Color.White else Color.White.copy(alpha = 0.14f), - RoundedCornerShape(14.dp), + RoundedCornerShape(MembyPanelCorner), ) .focusProperties { left = navigationFocusRequester } .onFocusChanged { @@ -1157,13 +1607,13 @@ private fun FavoriteShowsEmptyState( } Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - "No favorite shows yet", + "No favourite shows yet", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, ) Text( - "Mark a series as a favorite and it’ll be waiting here.", + "Mark a series as a favourite and it’ll be waiting here.", color = MutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -1183,12 +1633,17 @@ private fun GalleryJumpButton( onClick = { if (enabled) onClick() }, onLongClick = null, contentDescription = if (forward) "Next page" else "Previous page", - modifier = Modifier.width(42.dp).height(36.dp), + // These mirror remote left/right paging for pointer users. Keeping them out of + // the focus graph prevents Up from a card landing in a tiny header control. + modifier = Modifier + .width(42.dp) + .height(36.dp) + .focusProperties { canFocus = false }, ) { focused -> Box( Modifier .fillMaxSize() - .clip(RoundedCornerShape(7.dp)) + .clip(RoundedCornerShape(MembyChipCorner)) .background( when { focused -> Color.White.copy(alpha = 0.16f) @@ -1210,7 +1665,49 @@ private fun GalleryJumpButton( private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } -private fun cardFormat(kind: MediaRowKind, item: BaseItem): MediaCardFormat = when { +private val MediaRowHorizontalInset = 36.dp +private val MediaRowCardSpacing = 16.dp + +/** + * Sizes a shelf to an integer number of complete cards. + * + * The old fractional "cards across" values intentionally left the next card hanging + * outside the viewport. Besides looking clipped, that became more pronounced on smaller + * TVs and whenever the navigation rail reduced the content width. Work from the actual + * row width (including its padding and gaps) so every visible card has a complete edge. + */ +internal fun responsiveRowCardWidth( + availableWidth: Dp, + preferredCardsAcross: Int, + minWidth: Dp, + maxWidth: Dp, + horizontalInset: Dp = MediaRowHorizontalInset, + spacing: Dp = MediaRowCardSpacing, +): Dp { + val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp) + val cardsThatFitMinimum = + ((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1) + var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum) + + // Very wide viewports should gain another complete card instead of a large dead area. + while (cardCount < cardsThatFitMinimum) { + val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount + if (candidate <= maxWidth) break + cardCount += 1 + } + + return ((usableWidth - spacing * (cardCount - 1)) / cardCount) + .coerceAtMost(maxWidth) + .coerceAtLeast(1.dp) +} + +private fun cardFormat( + kind: MediaRowKind, + item: BaseItem, + artworkStyle: String = "automatic", +): MediaCardFormat = when { + artworkStyle == "poster" -> MediaCardFormat.PORTRAIT + artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE kind == MediaRowKind.CONTINUE || kind == MediaRowKind.NEXT_UP -> MediaCardFormat.LANDSCAPE item.isEpisode -> MediaCardFormat.LANDSCAPE else -> MediaCardFormat.PORTRAIT @@ -1225,11 +1722,18 @@ fun PortraitMediaCard( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + density: String = "standard", + showWatchedEpisodeCount: Boolean = false, ) { - val width = (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + val cardsAcross = when (density) { + "compact" -> 8 + "large" -> 5 + else -> 7 + } + val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp) MediaCard( item, width, 2f / 3f, preferPrimary = true, showProgress = false, - showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, ) } @@ -1248,10 +1752,11 @@ fun PosterGridCard( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + showWatchedEpisodeCount: Boolean = false, ) { MediaCard( item, width, 2f / 3f, preferPrimary = true, showProgress = false, - showSecondaryMetadata = true, onFocused, onClick, onLongClick, modifier, + showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, ) } @@ -1264,11 +1769,18 @@ fun LandscapeMediaCard( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + density: String = "standard", + showWatchedEpisodeCount: Boolean = false, ) { - val width = (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + val cardsAcross = when (density) { + "compact" -> 5 + "large" -> 3 + else -> 4 + } + val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) MediaCard( item, width, 16f / 9f, preferPrimary = false, showProgress = false, - showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, ) } @@ -1281,11 +1793,18 @@ fun ContinueWatchingCard( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, + density: String = "standard", + showWatchedEpisodeCount: Boolean = false, ) { - val width = (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + val cardsAcross = when (density) { + "compact" -> 5 + "large" -> 3 + else -> 4 + } + val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) MediaCard( item, width, 16f / 9f, preferPrimary = false, showProgress = true, - showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, ) } @@ -1297,6 +1816,7 @@ private fun MediaCard( preferPrimary: Boolean, showProgress: Boolean, showSecondaryMetadata: Boolean, + showWatchedEpisodeCount: Boolean, onFocused: () -> Unit, onClick: () -> Unit, onLongClick: () -> Unit, @@ -1336,7 +1856,7 @@ private fun MediaCard( onFocused = onFocused, onClick = onClick, onLongClick = onLongClick, - contentDescription = cardDescription(item, progress), + contentDescription = cardDescription(item, progress, showWatchedEpisodeCount), modifier = modifier.width(width), ) { focused -> Column { @@ -1344,16 +1864,20 @@ private fun MediaCard( Modifier .width(width) .aspectRatio(aspectRatio) - .then( - if (focused) Modifier.shadow(7.dp, RoundedCornerShape(9.dp)) - else Modifier, + // Keep the modifier node stable while focus changes. Adding/removing + // the shadow node forces the poster subtree to be rebuilt exactly + // when a row is moving into view, which shows up as a small hitch on + // lower-powered TV hardware. + .shadow( + elevation = if (focused) 7.dp else 0.dp, + shape = RoundedCornerShape(MembyCardCorner), ) - .clip(RoundedCornerShape(9.dp)) + .clip(RoundedCornerShape(MembyCardCorner)) .background(Color(0xFF20252A)) .border( 2.dp, if (focused) Color.White else Color.White.copy(alpha = 0.07f), - RoundedCornerShape(9.dp), + RoundedCornerShape(MembyCardCorner), ), contentAlignment = Alignment.Center, ) { @@ -1416,15 +1940,15 @@ private fun MediaCard( } } } - if (item.isSonarrSchedule) { + if (item.isSchedule) { ScheduleStatusBadge( status = item.membyAvailability.orEmpty(), modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), ) } - if (item.membyAiringToday) { + airingBadgeLabel(item)?.let { label -> MediaBadge( - "AIRING TODAY", + label, modifier = Modifier.align(Alignment.TopStart).padding(8.dp), ) } @@ -1440,7 +1964,7 @@ private fun MediaCard( ) if (showSecondaryMetadata) { Text( - cardSubtitle(item, showProgress, position), + cardSubtitle(item, showProgress, position, showWatchedEpisodeCount), color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -1457,12 +1981,13 @@ private fun MediaCard( @Composable private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) { - val (label, color) = when (status) { - "available" -> "ADDED" to EmbyGreen - "downloading" -> "DOWNLOADING" to Color(0xFF5DA9FF) - "awaiting" -> "AWAITING" to Color(0xFFFFB454) - "unmonitored" -> "UNMONITORED" to QuietText - else -> "TODAY" to Color(0xFFE1E5E8) + val label = scheduleStatusBadgeLabel(status) + val color = when (status) { + "available" -> EmbyGreen + "downloading" -> Color(0xFF5DA9FF) + "awaiting" -> Color(0xFFFFB454) + "unmonitored" -> QuietText + else -> Color(0xFFE1E5E8) } Text( text = label, @@ -1477,6 +2002,14 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) { ) } +internal fun scheduleStatusBadgeLabel(status: String): String = when (status) { + "available" -> "ADDED" + "downloading" -> "DOWNLOADING" + "awaiting" -> "AWAITING" + "unmonitored" -> "UNMONITORED" + else -> "UPCOMING" +} + @Composable fun FocusScaleContainer( onFocused: () -> Unit, @@ -1487,6 +2020,8 @@ fun FocusScaleContainer( content: @Composable BoxScope.(focused: Boolean) -> Unit, ) { var focused by remember { mutableStateOf(false) } + val pressScope = rememberCoroutineScope() + var holdJob by remember { mutableStateOf(null) } var remoteLongPressHandled by remember { mutableStateOf(false) } val scale = animateFloatAsState( targetValue = if (focused) 1.025f else 1f, @@ -1505,7 +2040,13 @@ fun FocusScaleContainer( } .onFocusChanged { focused = it.isFocused - if (it.isFocused) onFocused() + if (it.isFocused) { + onFocused() + } else { + holdJob?.cancel() + holdJob = null + remoteLongPressHandled = false + } } .then( if (onLongClick != null) { @@ -1518,16 +2059,26 @@ fun FocusScaleContainer( when { activationKey && native.action == android.view.KeyEvent.ACTION_DOWN && - native.repeatCount > 0 && + native.repeatCount == 0 && + holdJob == null && !remoteLongPressHandled -> { - remoteLongPressHandled = true - onLongClick() + holdJob = pressScope.launch { + delay(QuickActionsHoldDurationMillis) + remoteLongPressHandled = true + holdJob = null + onLongClick() + } true } activationKey && - native.action == android.view.KeyEvent.ACTION_UP && - remoteLongPressHandled -> { + native.action == android.view.KeyEvent.ACTION_DOWN -> true + activationKey && + native.action == android.view.KeyEvent.ACTION_UP -> { + val wasLongPress = remoteLongPressHandled + holdJob?.cancel() + holdJob = null remoteLongPressHandled = false + if (!wasLongPress) onClick() true } else -> false @@ -1558,16 +2109,16 @@ fun TvLoadingPlaceholder( modifier: Modifier = Modifier, ) { val width = if (portrait) { - (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + responsiveRowCardWidth(availableWidth, 7, 102.dp, 218.dp) } else { - (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + responsiveRowCardWidth(availableWidth, 4, 164.dp, 360.dp) } Column(modifier.width(width)) { Box( Modifier .width(width) .aspectRatio(if (portrait) 2f / 3f else 16f / 9f) - .clip(RoundedCornerShape(9.dp)), + .clip(RoundedCornerShape(MembyCardCorner)), ) { ArtworkLoadingSkeleton(Modifier.fillMaxSize()) } @@ -1595,41 +2146,63 @@ private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) { private const val BACKDROP_SETTLE_DELAY_MS = 240L -private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when { +internal fun watchedEpisodeCountLabel(item: BaseItem): String? { + if (!item.isSeries) return null + val total = item.recursiveItemCount?.takeIf { it > 0 } ?: return null + val unwatched = item.userData?.unplayedItemCount?.coerceIn(0, total) ?: return null + val watched = total - unwatched + return "$watched of $total watched" +} + +private fun cardSubtitle( + item: BaseItem, + showProgress: Boolean, + positionTicks: Long, + showWatchedEpisodeCount: Boolean, +): String = when { showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}" + showWatchedEpisodeCount -> watchedEpisodeCountLabel(item) + ?: defaultCardSubtitle(item) + else -> defaultCardSubtitle(item) +} + +private fun defaultCardSubtitle(item: BaseItem): String = when { !item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason - item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today" + item.isSchedule -> item.membyAirLabel ?: "Coming up" item.isEpisode -> item.seriesName ?: "Up next" item.productionYear != null && item.runtimeMinutes != null -> - "${item.productionYear} • ${formatTvRuntime(requireNotNull(item.runtimeMinutes))}" + "${item.productionYear}$FactSeparator${formatRuntime(requireNotNull(item.runtimeMinutes))}" item.productionYear != null -> item.productionYear.toString() item.isSeries -> "Series" else -> item.type } -private fun cardDescription(item: BaseItem, progress: Float): String = buildString { +internal fun airingBadgeLabel(item: BaseItem): String? = when { + item.isSchedule -> item.membyAirDayLabel + ?.takeIf(String::isNotBlank) + ?.uppercase() + item.membyAiringToday -> "AIRING TODAY" + else -> null +} + +private fun cardDescription( + item: BaseItem, + progress: Float, + showWatchedEpisodeCount: Boolean, +): String = buildString { append(item.name) item.seriesName?.let { append(", ").append(it) } if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched") if (item.userData?.played == true) append(", watched") + if (showWatchedEpisodeCount) watchedEpisodeCountLabel(item)?.let { append(", ").append(it) } if (item.isFavorite) append(", favourite") - if (item.isSonarrSchedule) { + if (item.isSchedule) { item.membyAirLabel?.let { append(", ").append(it) } item.membyAvailabilityText?.let { append(", ").append(it) } } } -private fun formatTvRuntime(minutes: Int): String { - val hours = minutes / 60 - val remainder = minutes % 60 - return when { - hours > 0 && remainder > 0 -> "${hours}h ${remainder}m" - hours > 0 -> "${hours}h" - else -> "${remainder}m" - } -} - private fun formatTvPosition(ticks: Long): String { val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0) - return formatTvRuntime(minutes) + return formatRuntime(minutes) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt new file mode 100644 index 0000000..609a847 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -0,0 +1,409 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +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.tv.material3.Icon +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.detail.heroFacts +import com.ponzischeme89.memby.ui.detail.ratingLabel +import com.ponzischeme89.memby.ui.theme.FactSeparator +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyPanelCorner +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembyScore + +internal const val HOME_HERO_ROW_ID = "home-movie-hero" + +/** + * The movie feature owns the header only while focus is still in that feature. Once the + * viewer moves into a home shelf, the header becomes the same focused-item metadata panel + * used by Movies, Shows and the other browse destinations. + */ +internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean = + hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID) + +/** + * A hero card and the reason 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) + +private const val LABEL_NEW = "NEW RELEASE" +private const val LABEL_POPULAR = "POPULAR" +private const val LABEL_LIBRARY = "FROM YOUR LIBRARY" + +/** Picks a deliberate mix of fresh and popular movies while preserving server ranking. */ +internal fun selectHomeHeroMovies(rows: List): List { + fun HomeBrowseRow.matches(vararg words: String): Boolean { + val label = "$id $title".lowercase() + return words.any(label::contains) + } + + val newReleases = rows + .filter { it.matches("latest", "recent", "new release", "just added") } + .flatMap(HomeBrowseRow::items) + .filter(BaseItem::isMovie) + val popular = rows + .filter { it.matches("popular", "trending", "recommended", "top pick") } + .flatMap(HomeBrowseRow::items) + .filter(BaseItem::isMovie) + val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie) + + fun List.labelled(label: String) = map { HomeHeroPick(it, label) } + + return buildList { + addAll( + listOfNotNull( + newReleases.getOrNull(0)?.let { HomeHeroPick(it, LABEL_NEW) }, + popular.getOrNull(0)?.let { HomeHeroPick(it, LABEL_POPULAR) }, + ), + ) + addAll( + listOfNotNull( + newReleases.getOrNull(1)?.let { HomeHeroPick(it, LABEL_NEW) }, + popular.getOrNull(1)?.let { HomeHeroPick(it, LABEL_POPULAR) }, + ), + ) + addAll(newReleases.labelled(LABEL_NEW)) + addAll(popular.labelled(LABEL_POPULAR)) + addAll(everyMovie.labelled(LABEL_LIBRARY)) + // distinctBy keeps the first appearance, so a title that is both new and popular + // keeps the label of the row it was drawn from first. + }.distinctBy { it.item.id }.take(4) +} + +@Composable +internal fun HomeMovieHero( + movies: List, + navigationFocusRequester: FocusRequester, + contentEntryFocusRequester: FocusRequester? = null, + returnFocusItemId: String? = null, + returnFocusRequester: FocusRequester? = null, + onItemFocused: (BaseItem) -> Unit, + onItemSelected: (BaseItem) -> Unit, + modifier: Modifier = Modifier, + previewArtwork: ImageBitmap? = null, +) { + if (movies.isEmpty()) return + + BoxWithConstraints(modifier.fillMaxWidth()) { + val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp) + Row( + modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val featured = movies.first() + var featuredModifier: Modifier = Modifier + .weight(1f) + .fillMaxHeight() + .focusProperties { left = navigationFocusRequester } + if (contentEntryFocusRequester != null) { + featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester) + } + if (featured.item.id == returnFocusItemId && returnFocusRequester != null) { + featuredModifier = featuredModifier.focusRequester(returnFocusRequester) + } + FeaturedMovieCard( + pick = featured, + onFocused = { onItemFocused(featured.item) }, + onClick = { onItemSelected(featured.item) }, + modifier = featuredModifier, + previewArtwork = previewArtwork, + ) + + Column( + modifier = Modifier.width(miniWidth).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + movies.drop(1).take(3).forEach { pick -> + var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth() + if (pick.item.id == returnFocusItemId && returnFocusRequester != null) { + miniModifier = miniModifier.focusRequester(returnFocusRequester) + } + MiniMovieCard( + pick = pick, + onFocused = { onItemFocused(pick.item) }, + onClick = { onItemSelected(pick.item) }, + modifier = miniModifier, + previewArtwork = previewArtwork, + ) + } + } + } + } +} + +/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */ +private fun labelTint(label: String): Color = when (label) { + LABEL_NEW -> Color(0x667253B7) + LABEL_POPULAR -> Color(0x66499BD5) + else -> Color(0x66C67A42) +} + +@Composable +private fun FeaturedMovieCard( + pick: HomeHeroPick, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier, + previewArtwork: ImageBitmap?, +) { + val item = pick.item + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + contentDescription = "Featured movie, ${item.name}", + modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), + ) { focused -> + Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) { + HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xF20A0D10), + 0.48f to Color(0xA80A0D10), + 1f to Color(0x160A0D10), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.72f to Color.Transparent, + 1f to Color(0xD9000000), + ), + ), + ) + // The card is a fixed height and this column is centred in it, so anything + // over budget is lost equally top and bottom — and the action, being last, went + // first. A wrapped title costs exactly what the synopsis is worth, so the + // synopsis is what stands down; the button is never the thing that is cut. + var titleLines by remember(item.id) { mutableIntStateOf(1) } + Column( + modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp), + ) { + Text( + pick.label, + color = MembyAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + item.name, + color = Color.White, + fontSize = 30.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + onTextLayout = { titleLines = it.lineCount }, + ) + Spacer(Modifier.height(9.dp)) + HeroFactLine(item) + if (titleLines == 1) { + item.overview?.takeIf(String::isNotBlank)?.let { overview -> + Spacer(Modifier.height(9.dp)) + Text( + overview, + color = MembyMutedText, + fontSize = 13.sp, + lineHeight = 17.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.height(14.dp)) + MembyPlayChip(label = "Play", focused = focused) + } + } + } +} + +/** + * Year, length, certificate and score — the same wording, order and gold as the card + * directly beneath it and the detail page it opens. The hero used to carry its own + * formatter and print "2026 • M • 124m" over a row printing "2026 • 2h 4m". + */ +@Composable +private fun HeroFactLine(item: BaseItem) { + val facts = heroFacts(item) + val score = ratingLabel(item) + if (facts.isEmpty() && score == null) return + Row(verticalAlignment = Alignment.CenterVertically) { + if (facts.isNotEmpty()) { + Text( + facts.joinToString(FactSeparator), + color = MembyMutedText, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + score?.let { + if (facts.isNotEmpty()) Text(FactSeparator, color = MembyQuietText, fontSize = 13.sp) + Text("★ $it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun MiniMovieCard( + pick: HomeHeroPick, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier, + previewArtwork: ImageBitmap?, +) { + val item = pick.item + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + contentDescription = "${pick.label} movie, ${item.name}", + modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), + ) { focused -> + Box(Modifier.fillMaxSize().background(Color(0xFF192027))) { + HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().background(labelTint(pick.label))) + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xE80A0D10), + 0.78f to Color(0x850A0D10), + 1f to Color(0x300A0D10), + ), + ), + ) + Column(Modifier.align(Alignment.CenterStart).padding(horizontal = 14.dp, vertical = 10.dp)) { + Text( + pick.label, + color = if (focused) MembyAccent else MembyQuietText, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Spacer(Modifier.height(4.dp)) + Text( + item.name, + color = Color.White, + fontSize = 15.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + item.productionYear?.let { year -> + Spacer(Modifier.height(3.dp)) + Text(year.toString(), color = MembyQuietText, fontSize = 10.sp) + } + } + if (focused) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .size(38.dp) + .shadow(14.dp, CircleShape) + .clip(CircleShape) + .background(MembyAccent) + .border(2.dp, Color.White, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + } + } +} + +@Composable +private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) { + if (previewArtwork != null) { + Image( + bitmap = previewArtwork, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + return + } + val repo = ServiceLocator.repository + val density = LocalDensity.current + val artworkWidth = with(density) { 760.dp.roundToPx() }.coerceIn(480, 1280) + val artwork = remember(item.id, artworkWidth) { + repo.backdropUrl(item, artworkWidth) ?: repo.primaryUrl(item, artworkWidth) + } + if (artwork != null) { + AsyncImage( + model = artwork, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else { + Box( + modifier.background( + Brush.linearGradient( + listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)), + ), + ), + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 26731ce..d375c15 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -10,7 +10,6 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.isMaintenanceError import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.UserItemData import kotlinx.coroutines.Dispatchers @@ -18,8 +17,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -50,15 +53,29 @@ data class HomeUiState( * rows behind it would be stale and unusable anyway. */ val maintenanceMessage: String? = null, - /** - * The gateway's update verdict. A mandatory one blocks the home screen; an optional - * one shows a prompt the viewer can dismiss for this session. - */ - val update: GatewayUpdate? = null, ) { val watchingAndNextUp: List get() = (continueWatching + nextUp).distinctBy(BaseItem::id) + /** + * This state with everything that is *not* a row blanked out. Paired with + * `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only + * emits when something changed about what is on the rows — see the note there. + * + * Read rows and [loading] from this; the blanked fields are meaningless in it. + */ + fun contentSlice(): HomeUiState = copy( + hasRefreshError = false, + statusMessage = null, + maintenanceMessage = null, + ) + + fun statusSlice(): HomeStatus = HomeStatus( + hasRefreshError = hasRefreshError, + statusMessage = statusMessage, + maintenanceMessage = maintenanceMessage, + ) + fun toCache() = HomeCache( continueWatching = continueWatching, nextUp = nextUp, @@ -84,6 +101,16 @@ data class HomeUiState( } } +/** + * The connection-health slice of [HomeUiState]: what the banner and the maintenance + * screen need, and nothing that would drag the rows into a recomposition with it. + */ +data class HomeStatus( + val hasRefreshError: Boolean = false, + val statusMessage: String? = null, + val maintenanceMessage: String? = null, +) + data class ForYouUiState( val rows: List = emptyList(), val availableMinutes: Int = 0, @@ -95,6 +122,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { private val refreshMutex = Mutex() private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome())) val state: StateFlow = _state.asStateFlow() + + // HomeScreen is a very large composable, so reading the whole of [state] there meant + // every emission invalidated the launcher: the slow- + // connection banner and each of the four section loads all recomposed the rows, the + // rail and every overlay, and rebuilt the row list with them. These three narrow + // projections let it subscribe only to what each part actually renders. + + /** Rows and their loading flags. Does not emit for banner changes. */ + val content: StateFlow = _state + .map(HomeUiState::contentSlice) + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice()) + + /** Connection health. Does not emit when rows change. */ + val status: StateFlow = _state + .map(HomeUiState::statusSlice) + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice()) + private val _focusedItem = MutableStateFlow(initialFocusedItem(_state.value)) val focusedItem: StateFlow = _focusedItem.asStateFlow() private val _forYou = MutableStateFlow(ForYouUiState()) @@ -107,20 +153,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { /** Row engagement, buffered here and uploaded in batches. */ private val analytics = RowAnalytics() - /** Optional prompt the viewer waved away; forgotten when the app restarts. */ - private var dismissedUpdateVersion: String? = null - init { refreshAll() - checkForAppUpdate() - viewModelScope.launch { - // A TV can leave Memby open for days. Keep checking at a deliberately slow - // cadence so a newly published release appears without requiring a restart. - while (true) { - delay(UPDATE_CHECK_INTERVAL_MS) - checkForAppUpdate() - } - } viewModelScope.launch { repository.playbackStops.collect { refreshWatching() } } @@ -134,29 +168,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } - /** - * Asks the gateway whether this build is still allowed. Runs on every launch, so an - * operator can retire a version without waiting for anyone to open Settings. - */ - fun checkForAppUpdate() { - viewModelScope.launch(Dispatchers.IO) { - val update = repository.checkAppUpdate() ?: return@launch - // A dismissed optional prompt stays dismissed for this session; a mandatory - // one always reasserts itself. - if (update.isOptional && dismissedUpdateVersion == update.version) return@launch - _state.update { it.copy(update = update) } - } - } - - /** Dismisses an optional prompt. Mandatory updates ignore this by construction. */ - fun dismissUpdatePrompt() { - val current = _state.value.update ?: return - if (current.isMandatory) return - dismissedUpdateVersion = current.version - _state.update { it.copy(update = null) } - } - - fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind) + fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List = emptyList()) = + analytics.rowImpression(rowId, rowKind, visibleItemIds) fun trackRowFocused(rowId: String, rowKind: String, itemId: String) = analytics.rowFocused(rowId, rowKind, itemId) @@ -286,6 +299,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { val cached = synchronized(metadataCache) { metadataCache[item.id] } val focused = (cached ?: item).copy( membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true, + membyRecommendationReason = item.membyRecommendationReason + ?: cached?.membyRecommendationReason, + membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility, ) _focusedItem.value = focused metadataJob?.cancel() @@ -298,7 +314,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { if (item.membyPlayable) { launch { runCatching { repository.prefetchPlayable(cached ?: item) } } } - if (cached == null && !item.isSonarrSchedule) { + // Warm the explanation and franchise siblings while the card is already + // focused, so opening Details does not add a reason line a frame later. + if (!item.isSchedule && (item.isMovie || item.isSeries)) { + launch { runCatching { repository.getRelated(focused) } } + } + if (cached == null && !item.isSchedule) { launch { val details = runCatching { repository.getItemDetails(item.id) @@ -464,7 +485,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { companion object { private const val FOCUS_METADATA_DEBOUNCE_MS = 140L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L - private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L private fun initialFocusedItem(state: HomeUiState): BaseItem? = state.watchingAndNextUp.firstOrNull() @@ -489,6 +509,7 @@ private fun List.airingTodayShowKeys(): Set = firstOrNull { it.id == "sonarr-airing-today" } ?.items .orEmpty() + .filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) } .mapTo(mutableSetOf()) { it.name.showMatchKey() } .filterTo(mutableSetOf(), String::isNotEmpty) @@ -497,7 +518,7 @@ private fun List.withAiringTodayRowTags(keys: Set): List.withAiringTodayItemTags(keys: Set): List = map { item -> - if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) { + if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) { item.copy(membyAiringToday = true) } else { item 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 9ea6ea2..e601b04 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -46,10 +46,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.setValue import androidx.compose.runtime.key import androidx.compose.animation.core.RepeatMode @@ -87,14 +89,18 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -104,55 +110,132 @@ import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest import com.ponzischeme89.memby.R +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.EmbyProfile -import com.ponzischeme89.memby.data.DeviceLimitException import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayUpdate +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.ServerConfig import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.performance.PerformanceMonitor import com.ponzischeme89.memby.ui.search.SearchScreen import com.ponzischeme89.memby.ui.settings.SettingsSheet -import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity +import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.update.UpdateChecker +import com.ponzischeme89.memby.update.ServerUpdateService import com.ponzischeme89.memby.update.UpdateStatus import androidx.tv.material3.Button import androidx.tv.material3.Card import androidx.tv.material3.Text +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.Date +/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */ +internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp = + if (showHero) { + (viewportHeight * 0.46f).coerceIn(232.dp, 300.dp) + } else { + (viewportHeight * 0.42f).coerceIn(220.dp, 300.dp) + } + +private val LazyListStateMapSaver = listSaver, Any>( + save = { states -> + states.flatMap { (key, state) -> + listOf(key, state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) + } + }, + restore = { values -> + mutableMapOf().apply { + values.chunked(3).forEach { saved -> + put( + saved[0] as String, + LazyListState(saved[1] as Int, saved[2] as Int), + ) + } + } + }, +) + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (intent.getBooleanExtra(EXTRA_LAUNCH_UPDATED_SLIDESHOW, false)) { - startActivity(ScreensaverActivity.restartAfterUpdateIntent(this)) - finish() - return - } setContent { MembyTheme { AppRoot(onCloseSettings = ::finish) } } PerformanceMonitor.start(this) } - - companion object { - const val EXTRA_LAUNCH_UPDATED_SLIDESHOW = "com.ponzischeme89.memby.extra.LAUNCH_UPDATED_SLIDESHOW" - } } +/** + * How long the launcher will wait for the gateway's onboarding verdict before opening the + * home screen anyway. Short on purpose: this is the one request left on the signed-in + * startup path, and everything it gates is already on disk. + */ +private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L +private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L +private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L + @Composable private fun AppRoot(onCloseSettings: () -> Unit) { val repo = ServiceLocator.repository - var settings by remember { mutableStateOf(null) } + // This client intentionally has no token provider and no dependency on the active + // profile. Updates are an app lifecycle concern, checked before login/session work. + val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } + var appUpdate by remember { mutableStateOf(null) } + var initialUpdateCheckComplete by remember { mutableStateOf(false) } + var dismissedUpdateVersion by rememberSaveable { mutableStateOf(null) } + // SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when + // Android recreates this activity after the viewer returns from the TV home screen; + // starting from null needlessly painted "Opening Memby..." while the replayed value + // made a round trip through a new collector. + val initialSettings = ServiceLocator.settings.current + var settings by remember { mutableStateOf(initialSettings) } var addingProfile by rememberSaveable { mutableStateOf(false) } var startingFirstRun by rememberSaveable { mutableStateOf(false) } var validatedToken by remember { mutableStateOf(null) } + var recommendationOnboarding by remember { + mutableStateOf( + initialSettings + ?.takeIf { it.hasCompletedOnboarding || !it.homeCacheJson.isNullOrBlank() } + ?.let { RecommendationOnboarding(completed = true) }, + ) + } + var onboardingToken by remember { mutableStateOf(null) } + LaunchedEffect(updateService) { + var firstCheck = true + while (true) { + val result = if (firstCheck) { + // A disconnected server must not strand an otherwise usable TV at boot. + withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() } + } else { + updateService.check() + } + result?.onSuccess { decision -> + appUpdate = decision?.takeUnless { + it.isOptional && dismissedUpdateVersion == it.version + } + } + if (firstCheck) { + initialUpdateCheckComplete = true + firstCheck = false + } + kotlinx.coroutines.delay(UPDATE_CHECK_INTERVAL_MS) + } + } LaunchedEffect(repo) { repo.settingsFlow.collect { settings = it } } - LaunchedEffect(settings?.token) { + LaunchedEffect(settings?.token, initialUpdateCheckComplete, appUpdate?.version) { + if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect if (validatedToken == token) return@LaunchedEffect validatedToken = token @@ -160,15 +243,113 @@ private fun AppRoot(onCloseSettings: () -> Unit) { repo.invalidateSession() } } + LaunchedEffect( + settings?.token, + settings?.userId, + initialUpdateCheckComplete, + appUpdate?.version, + ) { + if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect + val loaded = settings ?: return@LaunchedEffect + val token = loaded.token?.takeIf { loaded.isSignedIn && it.isNotBlank() } + ?: run { + recommendationOnboarding = null + onboardingToken = null + return@LaunchedEffect + } + // The guard stops a settings emission that did not change the session from asking + // the gateway again. It must never be able to leave the answer unresolved: this + // effect is cancelled and restarted whenever the token or user id changes, so a + // restart that arrived while the previous run was still in flight would otherwise + // find its own token already recorded, return early, and strand the launcher on + // the loading screen for good. + if (onboardingToken == token && recommendationOnboarding != null) return@LaunchedEffect + onboardingToken = token + // A profile that has already onboarded on this TV never waits for the gateway to + // confirm it. Onboarding is one-time and monotonic, so the persisted flag is as + // good an answer as the round-trip — and holding the launcher behind that request + // meant a returning viewer could not see their cached rows until the network + // answered, or until the connect timeout expired if it never did. + if (loaded.hasCompletedOnboarding) { + recommendationOnboarding = RecommendationOnboarding(completed = true) + return@LaunchedEffect + } + // A saved home cache can only have been created after this profile previously + // reached HomeScreen. Treat it as the migration signal for profiles created before + // ONBOARDED_USERS existed, so an app update does not add a network round-trip to + // every launch (and repeat it whenever that request times out). + if (!loaded.homeCacheJson.isNullOrBlank()) { + recommendationOnboarding = RecommendationOnboarding(completed = true) + loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } + return@LaunchedEffect + } + // Do not leave a previous profile's completed verdict visible while a genuinely + // new profile is being checked. + recommendationOnboarding = null + // This request decides which screen opens, so the launcher waits on it — which + // makes its worst case the app's worst case. Left unbounded that is the gateway + // client's connect plus read timeout, half a minute of "Opening Memby…" on a TV + // whose cached rows were ready the whole time. Past this budget the viewer goes + // to their rows and a profile that genuinely has not onboarded is asked again on + // the next launch, which is the cheaper mistake by a wide margin. + val answered = runCatching { + withTimeoutOrNull(ONBOARDING_CHECK_TIMEOUT_MS) { repo.getRecommendationOnboarding() } + }.getOrNull() + // runCatching swallows cancellation too, so say so explicitly: a run abandoned + // because the session changed must not write its verdict over the one the + // restarted effect is about to produce. + currentCoroutineContext().ensureActive() + // Only a completion the gateway actually reported is remembered. Falling back to + // "completed" keeps a viewer out of onboarding during an outage, but persisting + // that guess would silently retire the rating screen for someone who has never + // seen it, on nothing more than one slow response. + if (answered?.completed == true) { + loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } + } + // Do not trap an existing viewer behind onboarding during a gateway outage. + recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true) + } Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) { val loaded = settings when { - loaded == null -> MembyLoadingScreen() + !initialUpdateCheckComplete -> MembyLoadingScreen( + quoteStyle = loaded?.welcomeQuoteStyle, + ) + appUpdate != null -> UpdateScreen( + update = appUpdate!!, + onDismiss = { + val current = appUpdate ?: return@UpdateScreen + if (!current.isMandatory) { + dismissedUpdateVersion = current.version + appUpdate = null + } + }, + ) + loaded == null -> MembyLoadingScreen( + quoteStyle = ServiceLocator.settings.current?.welcomeQuoteStyle, + ) addingProfile -> SetupScreen( onCancel = { addingProfile = false }, onSignedIn = { addingProfile = false }, ) + loaded.isSignedIn && recommendationOnboarding == null -> + MembyLoadingScreen(quoteStyle = loaded.welcomeQuoteStyle) + loaded.isSignedIn && recommendationOnboarding?.completed == false -> { + RecommendationOnboardingScreen( + onboarding = recommendationOnboarding!!, + onComplete = { ratings -> + repo.saveRecommendationRatings(ratings) + // Record it locally as well, so this profile's next cold start + // skips the gateway check entirely. + loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) } + recommendationOnboarding = recommendationOnboarding!!.copy( + completed = true, + ratings = ratings, + ) + }, + ) + } loaded.isSignedIn -> { BackHandler(onBack = onCloseSettings) key(loaded.userId, loaded.serverUrl) { @@ -192,7 +373,8 @@ private fun AppRoot(onCloseSettings: () -> Unit) { } @Composable -private fun MembyLoadingScreen() { +private fun MembyLoadingScreen(quoteStyle: String? = null) { + val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } val transition = rememberInfiniteTransition(label = "cold-start") val pulse by transition.animateFloat( initialValue = 0.96f, @@ -246,6 +428,14 @@ private fun MembyLoadingScreen() { fontSize = 17.sp, fontWeight = FontWeight.Medium, ) + Text( + welcomeQuote, + color = Color.White.copy(alpha = 0.58f), + fontSize = 13.sp, + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.width(440.dp), + ) Box( Modifier .width(92.dp) @@ -437,6 +627,8 @@ private fun SetupScreen( val usernameFocus = remember { FocusRequester() } val passwordFocus = remember { FocusRequester() } + val signInFocus = remember { FocusRequester() } + val backFocus = remember { FocusRequester() } LaunchedEffect(Unit) { kotlinx.coroutines.delay(100L) runCatching { usernameFocus.requestFocus() } @@ -459,22 +651,16 @@ private fun SetupScreen( ) } .onSuccess { authenticatedUsername -> + val quoteStyle = ServiceLocator.settings.snapshot().welcomeQuoteStyle Toast.makeText( context, - loginWelcomeMessage(authenticatedUsername), + loginWelcomeMessage(authenticatedUsername, quoteStyle), Toast.LENGTH_LONG, ).show() onSignedIn() } .onFailure { failure -> - error = when (failure) { - is DeviceLimitException -> - "Device limit reached: this account is using " + - "${failure.activeClients} of ${failure.maxClients} devices. " + - "Sign out on another TV, then try again." - else -> - "Memby couldn't sign in. Check the username and password and try again." - } + error = "Memby couldn't sign in. Check the username and password and try again." } connecting = false } @@ -553,26 +739,38 @@ private fun SetupScreen( onValueChange = { password = it; error = null }, isPassword = true, focusRequester = passwordFocus, + downFocusRequester = signInFocus, onDone = submit, ) error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 16.sp) } - Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Button( - onClick = submit, - enabled = !connecting, - ) { - Text(if (connecting) "Signing in…" else "Sign in") - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.End), + ) { if (onCancel != null) { Button( onClick = onCancel, enabled = !connecting, + modifier = Modifier + .focusRequester(backFocus) + .focusProperties { right = signInFocus }, ) { Text("Back") } } + Button( + onClick = submit, + enabled = !connecting, + modifier = Modifier + .focusRequester(signInFocus) + .focusProperties { + if (onCancel != null) left = backFocus + }, + ) { + Text(if (connecting) "Signing in…" else "Sign in") + } } } } @@ -609,11 +807,13 @@ private fun ProfileEntryScreen( val repo = ServiceLocator.repository val scope = rememberCoroutineScope() var switchingProfileId by remember { mutableStateOf(null) } + var removingProfileId by remember { mutableStateOf(null) } ProfileChooser( profiles = settings.profiles, currentProfileId = null, switchingProfileId = switchingProfileId, + removingProfileId = removingProfileId, onSelect = { profile -> switchingProfileId = profile.id scope.launch { @@ -621,6 +821,13 @@ private fun ProfileEntryScreen( .onFailure { switchingProfileId = null } } }, + onRemove = { profile -> + removingProfileId = profile.id + scope.launch { + runCatching { repo.removeProfile(profile) } + removingProfileId = null + } + }, onAddProfile = onAddProfile, onClose = null, ) @@ -631,63 +838,344 @@ private fun ProfileChooser( profiles: List, currentProfileId: String?, switchingProfileId: String?, + removingProfileId: String?, onSelect: (EmbyProfile) -> Unit, + onRemove: (EmbyProfile) -> Unit, onAddProfile: () -> Unit, onClose: (() -> Unit)?, ) { val firstFocus = remember { FocusRequester() } + var pendingRemoval by remember { mutableStateOf(null) } val orderedProfiles = remember(profiles, currentProfileId) { profiles.sortedByDescending { it.id == currentProfileId } } LaunchedEffect(orderedProfiles.firstOrNull()?.id) { firstFocus.requestFocus() } - Column( + Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF090B0D)) - .padding(horizontal = 72.dp, vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .background(Color(0xFF090B0D)), ) { - androidx.compose.foundation.Image( - painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo), - contentDescription = "Memby", - modifier = Modifier.width(72.dp).height(60.dp), - ) - Spacer(Modifier.height(20.dp)) - Text("Who’s watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) - Text( - if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…", - color = Color(0xFFAAB1B7), - fontSize = 17.sp, - modifier = Modifier.padding(top = 8.dp, bottom = 30.dp), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.Top, + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 72.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { - orderedProfiles.forEachIndexed { index, profile -> + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = "Memby", + modifier = Modifier.width(72.dp).height(60.dp), + ) + Spacer(Modifier.height(20.dp)) + Text("Who’s watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) + Text( + when { + removingProfileId != null -> "Removing user…" + switchingProfileId != null -> "Switching profile…" + else -> "Choose a profile to continue" + }, + color = Color(0xFFAAB1B7), + fontSize = 17.sp, + modifier = Modifier.padding(top = 8.dp, bottom = 30.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top, + ) { + orderedProfiles.forEachIndexed { index, profile -> + Box(modifier = Modifier.width(154.dp)) { + ProfileTile( + name = profile.username, + current = profile.id == currentProfileId, + enabled = switchingProfileId == null && removingProfileId == null, + onClick = { onSelect(profile) }, + modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier, + ) + ProfileDeleteButton( + profileName = profile.username, + enabled = switchingProfileId == null && removingProfileId == null, + onClick = { pendingRemoval = profile }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 7.dp, end = 22.dp), + ) + } + } ProfileTile( - name = profile.username, - current = profile.id == currentProfileId, - enabled = switchingProfileId == null, - onClick = { onSelect(profile) }, - modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier, + 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, - symbol = "+", - onClick = onAddProfile, - modifier = if (orderedProfiles.isEmpty()) Modifier.focusRequester(firstFocus) else Modifier, + if (onClose != null) { + Spacer(Modifier.height(30.dp)) + Button( + onClick = onClose, + enabled = switchingProfileId == null && removingProfileId == null, + ) { Text("Back to Memby") } + } + } + pendingRemoval?.let { profile -> + ProfileRemovalConfirmation( + profile = profile, + onCancel = { pendingRemoval = null }, + onConfirm = { + pendingRemoval = null + onRemove(profile) + }, ) } - if (onClose != null) { - Spacer(Modifier.height(30.dp)) - Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Memby") } + } +} + +@Composable +private fun RecommendationOnboardingScreen( + onboarding: RecommendationOnboarding, + onComplete: suspend (Map) -> Unit, +) { + val repo = ServiceLocator.repository + val scope = rememberCoroutineScope() + val ratings = remember(onboarding.ratings) { + mutableStateMapOf().apply { putAll(onboarding.ratings) } + } + var index by rememberSaveable { mutableStateOf(0) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val items = onboarding.items + val item = items.getOrNull(index.coerceIn(0, (items.size - 1).coerceAtLeast(0))) + + val finish: () -> Unit = { + if (!saving) { + saving = true + error = null + scope.launch { + runCatching { onComplete(ratings.toMap()) } + .onFailure { + error = "Memby couldn't save those ratings. Please try again." + saving = false + } + } + } + } + + Box( + Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + listOf(Color(0xFF1B2B23), Color(0xFF090C0F)), + radius = 1_300f, + ), + ) + .padding(horizontal = 68.dp, vertical = 38.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Make Memby yours", + color = Color.White, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "Rate a few movies and TV series you know. These ratings shape your recommendations immediately.", + color = Color(0xFFB8C2C9), + fontSize = 17.sp, + textAlign = TextAlign.Center, + ) + if (item == null) { + Spacer(Modifier.weight(1f)) + Text("No rating choices are available yet.", color = Color(0xFFB8C2C9)) + Button(onClick = finish, enabled = !saving) { Text("Continue") } + Spacer(Modifier.weight(1f)) + } else { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(30.dp), + ) { + AsyncImage( + model = repo.primaryUrl(item, 420), + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(210.dp) + .height(315.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF222A30)), + ) + Column( + modifier = Modifier.width(520.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + if (item.isMovie) "MOVIE" else "TV SERIES", + color = Color(0xFF78D970), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + Text( + item.name, + color = Color.White, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull( + item.productionYear?.takeIf { it > 0 }?.toString(), + item.genres.take(2).joinToString(" · ").takeIf { it.isNotBlank() }, + ).joinToString(" · "), + color = Color(0xFFABB5BD), + fontSize = 15.sp, + ) + Text("Your rating", color = Color.White, fontSize = 17.sp) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + (1..5).forEach { rating -> + Button( + onClick = { + ratings[item.id] = rating + if (index < items.lastIndex) index++ + }, + ) { + Text( + if (ratings[item.id] == rating) "$rating ★" else "$rating", + fontSize = 17.sp, + ) + } + } + } + Text( + when (ratings[item.id]) { + 1 -> "Not for me" + 2 -> "Didn't like it" + 3 -> "It was okay" + 4 -> "Liked it" + 5 -> "Loved it" + else -> "Choose 1–5, or skip titles you haven't seen." + }, + color = Color(0xFF9DA8B0), + fontSize = 14.sp, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { index = (index - 1).coerceAtLeast(0) }, enabled = index > 0) { + Text("Previous") + } + Button( + onClick = { index = (index + 1).coerceAtMost(items.lastIndex) }, + enabled = index < items.lastIndex, + ) { Text("Haven't seen it") } + Text( + "${index + 1} of ${items.size} · ${ratings.size} rated", + color = Color(0xFF9DA8B0), + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 10.dp), + ) + Button(onClick = finish, enabled = !saving) { + Text(if (saving) "Saving…" else "Finish") + } + } + error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 15.sp) } + } + } + } +} + +@Composable +private fun ProfileDeleteButton( + profileName: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(28.dp) + .zIndex(2f) + .clip(CircleShape) + .background( + when { + !enabled -> Color(0xFF343A3F) + focused -> Color(0xFFE34B4B) + else -> Color(0xE61A1D20) + }, + ) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White else Color.White.copy(alpha = 0.55f), + shape = CircleShape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(enabled = enabled, onClick = onClick) + .semantics { contentDescription = "Remove $profileName" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "×", + color = if (enabled) Color.White else Color(0xFF899198), + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun ProfileRemovalConfirmation( + profile: EmbyProfile, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + val cancelFocus = remember { FocusRequester() } + BackHandler(onBack = onCancel) + LaunchedEffect(profile.id) { cancelFocus.requestFocus() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.78f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(460.dp) + .background(Color(0xFF20262B), RoundedCornerShape(18.dp)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + "Remove ${profile.username} from this TV?", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Their saved sign-in will be forgotten on this device.", + color = Color(0xFFBCC4CA), + fontSize = 16.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Button( + onClick = onCancel, + modifier = Modifier.focusRequester(cancelFocus), + ) { Text("Cancel") } + Button(onClick = onConfirm) { Text("Remove user") } + } } } } @@ -762,23 +1250,53 @@ private fun HomeScreen( viewModelStoreOwner = profileViewModelOwner, factory = factory, ) - val homeState by homeViewModel.state.collectAsStateWithLifecycle() + // Three narrow projections rather than the whole HomeUiState: this function is large + // enough that subscribing to every field made an arriving update verdict or a banner + // change recompose the launcher and rebuild every row. See HomeViewModel.content. + val homeContent by homeViewModel.content.collectAsStateWithLifecycle() + val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() var showSettings by remember { mutableStateOf(false) } var showProfiles by remember { mutableStateOf(false) } + var userSwitcherVisible by remember { mutableStateOf(false) } var switchingProfileId by remember { mutableStateOf(null) } + var removingProfileId by remember { mutableStateOf(null) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } - var navigationExpanded by remember { mutableStateOf(false) } + var navigationExpanded by rememberSaveable { mutableStateOf(false) } + var restoreRailAfterSettings by remember { mutableStateOf(false) } var detailsItem by remember { mutableStateOf(null) } + // Detail pages can now open one another through the related rail. Back walks the trail + // home one page at a time; without this it would drop three levels to the launcher. + var detailsTrail by remember { mutableStateOf>(emptyList()) } var quickMenuItem by remember { mutableStateOf(null) } + var quickMenuRowId by remember { mutableStateOf(null) } + var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } + var selectedMyShow by remember { mutableStateOf(null) } + var removingMyShow by remember { mutableStateOf(false) } + var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) } + var showNotifications by remember { mutableStateOf(false) } var launchingItem by remember { mutableStateOf(null) } var returnRowId by rememberSaveable { mutableStateOf(null) } var returnItemId by rememberSaveable { mutableStateOf(null) } var recentSearches by remember { mutableStateOf>(emptyList()) } var initialSearchQuery by rememberSaveable { mutableStateOf(null) } + // Destination and row list states live above the conditional content branches. + // Opening Search/Settings/details therefore never creates a new list at position 0. + val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) { + mutableMapOf() + } + val horizontalStates = rememberSaveable(saver = LazyListStateMapSaver) { + mutableMapOf() + } + val rememberedRowFocus = remember { + mutableMapOf>() + } + val destinationFocus = remember { + mutableMapOf>() + } var showForYouNudge by rememberSaveable(settings.activeProfileId) { mutableStateOf(!settings.hasOpenedForYou) } @@ -798,6 +1316,8 @@ private fun HomeScreen( // needs one frame to reattach the saved card's focus node before it can receive // focus, especially when playback progress refreshed the row behind the player. scope.launch { + runCatching { repo.getMyShows() }.onSuccess { myShows = it } + runCatching { repo.getNotifications() }.onSuccess { notificationState = it } kotlinx.coroutines.delay(32L) if (returnRowId != null && returnItemId != null) { runCatching { cardReturnFocusRequester.requestFocus() } @@ -805,14 +1325,21 @@ private fun HomeScreen( } } + LaunchedEffect(settings.userId) { + runCatching { repo.getMyShows() }.onSuccess { myShows = it } + runCatching { repo.getNotifications() }.onSuccess { notificationState = it } + } + LaunchedEffect(liveMaintenance) { if (liveMaintenance != null) { // Maintenance is an app-wide interruption, not another overlay in the stack. showSettings = false showProfiles = false + userSwitcherVisible = false detailsItem = null quickMenuItem = null - } else if (homeState.maintenanceMessage != null) { + quickMenuRowId = null + } else if (homeStatus.maintenanceMessage != null) { // A successful live status is authoritative; refresh content immediately // instead of waiting for the maintenance screen's slower retry timer. homeViewModel.refreshAll() @@ -834,6 +1361,12 @@ private fun HomeScreen( title = playable.title, resumePositionMs = playable.resumePositionMs, logoUrl = playable.logoUrl, + overview = playable.overview ?: item.overview, + episodeCode = playable.episodeCode, + runtimeMs = playable.runtimeMs, + prerollEnabled = playable.prerollEnabled, + prerollDurationMs = playable.prerollDurationMs, + posterUrl = repo.primaryUrl(item, maxWidth = 500), subtitles = playable.subtitles, mediaSourceId = playable.mediaSourceId, playSessionId = playable.playSessionId, @@ -852,17 +1385,22 @@ private fun HomeScreen( } val rows = remember( - homeState, + homeContent, forYouState, selectedDestination, settings.homeSections, settings.showHomeCardMetadata, + settings.hideWatchedMovies, ) { - if (selectedDestination == BrowseDestination.FOR_YOU) { + val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) { forYouBrowseRows(forYouState) } else { - homeRowsFor(selectedDestination, homeState, settings) + homeRowsFor(selectedDestination, homeContent, settings) } + applyWatchedVisibility(destinationRows, settings.hideWatchedMovies) + } + val homeHeroMovies = remember(rows, selectedDestination) { + if (selectedDestination == BrowseDestination.HOME) selectHomeHeroMovies(rows) else emptyList() } LaunchedEffect(selectedDestination) { if (selectedDestination == BrowseDestination.FAVORITES) { @@ -885,8 +1423,13 @@ private fun HomeScreen( showForYouNudge = false } } - LaunchedEffect(selectedDestination, rows.firstOrNull()?.items?.firstOrNull()?.id) { - val firstItem = rows.firstNotNullOfOrNull { it.items.firstOrNull() } + LaunchedEffect( + selectedDestination, + homeHeroMovies.firstOrNull()?.item?.id, + rows.firstOrNull()?.items?.firstOrNull()?.id, + ) { + val firstItem = homeHeroMovies.firstOrNull()?.item + ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() } firstItem?.let(homeViewModel::focusItem) if (firstItem != null && !initialFocusRequested) { kotlinx.coroutines.delay(16L) @@ -922,19 +1465,38 @@ private fun HomeScreen( selected = selectedDestination, expanded = navigationExpanded, navigationFocusRequester = navigationFocusRequester, - onRailFocusChanged = { navigationExpanded = it }, + onRailFocusChanged = { + navigationExpanded = it + }, onDestinationSelected = { destination -> - navigationExpanded = false when (destination) { - BrowseDestination.SETTINGS -> showSettings = true - BrowseDestination.PROFILES -> showProfiles = true + BrowseDestination.SETTINGS -> { + restoreRailAfterSettings = true + navigationExpanded = false + userSwitcherVisible = false + showSettings = true + } + BrowseDestination.PROFILES -> { + userSwitcherVisible = true + navigationExpanded = false + } else -> { + navigationExpanded = false + userSwitcherVisible = false selectedDestination = destination + destinationFocus[destination]?.let { (rowId, itemId) -> + returnRowId = rowId + returnItemId = itemId + } scope.launch { // Let the destination compose and attach its entry target // before transferring focus out of the rail. kotlinx.coroutines.delay(16L) - runCatching { contentFocusRequester.requestFocus() } + if (destinationFocus.containsKey(destination)) { + runCatching { cardReturnFocusRequester.requestFocus() } + } else { + runCatching { contentFocusRequester.requestFocus() } + } } } } @@ -946,7 +1508,7 @@ private fun HomeScreen( .fillMaxSize() .offset { IntOffset(x = contentShift.roundToPx(), y = 0) }, ) { - val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage + val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage if (maintenanceMessage != null) { // The rows are gone but the rail is not: Settings and Switch user are // local, so there is no reason to strand the viewer here. @@ -966,9 +1528,9 @@ private fun HomeScreen( if (selectedDestination == BrowseDestination.SEARCH) { // Remembered, or this list is a fresh instance on every recomposition // and the screen re-derives its genre chips each time. - val discovery = remember(homeState.rows, homeState.latestMovies, homeState.continueWatching) { - homeState.rows.flatMap { it.items } - .ifEmpty { homeState.watchingAndNextUp + homeState.latestMovies } + val discovery = remember(homeContent.rows, homeContent.latestMovies, homeContent.continueWatching) { + homeContent.rows.flatMap { it.items } + .ifEmpty { homeContent.watchingAndNextUp + homeContent.latestMovies } .distinctBy(BaseItem::id) } // Its own pane rather than a row list: the keyboard and the results @@ -988,6 +1550,7 @@ private fun HomeScreen( onItemSelected = { item -> returnRowId = SEARCH_ROW_ID returnItemId = item.id + destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id homeViewModel.focusItem(item) if (item.membyPlayable) detailsItem = item }, @@ -1004,33 +1567,73 @@ private fun HomeScreen( } FocusedHomeBackdrop(homeViewModel) - val metadataHeight = (maxHeight * 0.42f).coerceIn(220.dp, 300.dp) + val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty() + val showHomeHero = shouldShowHomeMovieHero( + hasMovies = hasHomeHero, + // returnRowId is also used while switching destinations and can still + // name the page we just left. Destination focus is the authoritative + // record of which part of Home the viewer actually last used. + focusedRowId = destinationFocus[BrowseDestination.HOME]?.first, + ) + val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero) val contentWidth = maxWidth HomeArtworkPreloader(rows = rows, availableWidth = contentWidth) - val verticalState = rememberSaveable( - selectedDestination.name, - saver = LazyListState.Saver, - ) { LazyListState() } + val verticalState = verticalStates.getOrPut(selectedDestination.name) { + LazyListState() + } + val rowFocusPositions = rememberedRowFocus.getOrPut(selectedDestination) { + mutableMapOf() + } + val rowIndexes = remember(rows) { + rows.mapIndexed { index, row -> row.id to index }.toMap() + } + val rowItemCounts = remember(rows) { rows.map { it.items.size } } + var pendingRowFocus by remember(selectedDestination) { + mutableStateOf(null) + } + var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } + var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } Column(Modifier.fillMaxSize()) { - FocusedHomeMetadata( - homeViewModel = homeViewModel, - sectionLabel = selectedDestination.label, - navigationFocusRequester = navigationFocusRequester, - onPlay = playItem, - onContentFocused = { navigationExpanded = false }, - modifier = Modifier - .height(metadataHeight) - .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), - ) + if (showHomeHero) { + HomeMovieHero( + movies = homeHeroMovies, + navigationFocusRequester = navigationFocusRequester, + contentEntryFocusRequester = contentFocusRequester, + returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID }, + returnFocusRequester = cardReturnFocusRequester, + onItemFocused = { item -> + navigationExpanded = false + destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id + returnRowId = HOME_HERO_ROW_ID + returnItemId = item.id + homeViewModel.focusItem(item) + }, + onItemSelected = { item -> + returnRowId = HOME_HERO_ROW_ID + returnItemId = item.id + homeViewModel.focusItem(item) + playItem(item) + }, + modifier = Modifier.height(metadataHeight), + ) + } else { + FocusedHomeMetadata( + homeViewModel = homeViewModel, + sectionLabel = selectedDestination.label, + modifier = Modifier + .height(metadataHeight) + .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), + ) + } androidx.compose.animation.AnimatedVisibility( - visible = homeState.hasRefreshError, + visible = homeStatus.hasRefreshError, enter = androidx.compose.animation.fadeIn(tween(180)), exit = androidx.compose.animation.fadeOut(tween(120)), ) { Text( // The gateway's maintenance notice when it sent one, and the // generic wording otherwise. - homeState.statusMessage ?: "Connection is slow — showing available content", + homeStatus.statusMessage ?: "Connection is slow — showing available content", color = Color(0xFFD1D5D8), fontSize = 14.sp, modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), @@ -1040,11 +1643,24 @@ private fun HomeScreen( state = verticalState, modifier = Modifier .weight(1f) - .fillMaxWidth() - .padding(bottom = 32.dp), + .fillMaxWidth(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + if (selectedDestination == BrowseDestination.SHOWS) { + item(key = "my-shows", contentType = "my-shows") { + MyShowsStrip( + shows = myShows, + repository = repo, + availableWidth = contentWidth, + density = settings.homeCardDensity, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester.takeIf { myShows.isNotEmpty() }, + onShowSelected = { selectedMyShow = it }, + onContentFocused = { navigationExpanded = false }, + ) + } + } if ( selectedDestination == BrowseDestination.FAVORITES && recentSearches.isNotEmpty() @@ -1082,19 +1698,78 @@ private fun HomeScreen( // LazyColumn composes a row as it scrolls into view, which is // as close to "the viewer saw it" as the TV can observe. LaunchedEffect(row.id) { - homeViewModel.trackRowImpression(row.id, row.kind.name) + // The first eight posters cover the initial TV viewport + // with headroom across density settings. Later posters + // only become evidence when focus actually reaches them. + homeViewModel.trackRowImpression( + row.id, + row.kind.name, + row.items.take(8).map { it.id }, + ) } MediaRow( row = row, availableWidth = contentWidth, navigationFocusRequester = navigationFocusRequester, contentEntryFocusRequester = contentFocusRequester.takeIf { - row.id == rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id + !hasHomeHero && + (selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) && + row.id == rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id }, returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, returnFocusRequester = cardReturnFocusRequester, + verticalFocusRequest = pendingRowFocus, + onVerticalFocusRequestConsumed = { requestId -> + if (pendingRowFocus?.requestId == requestId) { + pendingRowFocus = null + rowFocusMoving = false + } + }, + onMoveVertical = moveVertical@{ sourceItemIndex, direction -> + if (rowFocusMoving) return@moveVertical true + val sourceRowIndex = rowIndexes[row.id] ?: return@moveVertical false + val destinationRowIndex = adjacentFocusableRowIndex( + itemCounts = rowItemCounts, + currentIndex = sourceRowIndex, + direction = direction, + ) ?: return@moveVertical false + val destinationRow = rows[destinationRowIndex] + val destinationItemIndex = rowEntryItemIndex( + sourceIndex = sourceItemIndex, + destinationItemCount = destinationRow.items.size, + rememberedDestinationIndex = rowFocusPositions[destinationRow.id], + ) + rowFocusPositions[row.id] = sourceItemIndex + rowFocusPositions[destinationRow.id] = destinationItemIndex + rowFocusRequestId += 1 + val request = RowFocusRequest( + rowId = destinationRow.id, + itemIndex = destinationItemIndex, + requestId = rowFocusRequestId, + ) + val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + + if ( + selectedDestination == BrowseDestination.FAVORITES && + recentSearches.isNotEmpty() + ) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0 + rowFocusMoving = true + scope.launch { + // Compose the destination row before its MediaRow tries + // to attach the requested card's focus node. + verticalState.animateScrollToItem( + leadingItemCount + destinationRowIndex, + ) + pendingRowFocus = request + } + true + }, onContentFocused = { navigationExpanded = false }, onItemFocused = { item -> + val itemIndex = row.items.indexOfFirst { it.id == item.id } + if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex + destinationFocus[selectedDestination] = row.id to item.id + returnRowId = row.id + returnItemId = item.id homeViewModel.focusItem(item) homeViewModel.trackRowFocused(row.id, row.kind.name, item.id) }, @@ -1109,8 +1784,16 @@ private fun HomeScreen( returnRowId = row.id returnItemId = item.id homeViewModel.focusItem(item) - if (item.membyPlayable) quickMenuItem = item + if (item.membyPlayable) { + quickMenuRowId = row.id + quickMenuItem = item + } }, + density = settings.homeCardDensity, + artworkStyle = settings.homeArtworkStyle, + horizontalState = horizontalStates.getOrPut( + "${selectedDestination.name}:${row.id}", + ) { LazyListState() }, ) } } @@ -1139,9 +1822,81 @@ 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 + scope.launch { + kotlinx.coroutines.delay(16L) + runCatching { navigationFocusRequester.requestFocus() } + } + } + UserSwitcherOverlay( + profiles = settings.profiles, + activeProfileId = settings.activeProfileId, + onProfileSelected = { profile -> + userSwitcherVisible = false + navigationExpanded = false + if (profile.id != settings.activeProfileId) { + switchingProfileId = profile.id + scope.launch { + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + } else { + scope.launch { + kotlinx.coroutines.delay(16L) + runCatching { navigationFocusRequester.requestFocus() } + } + } + }, + onManageProfiles = { + userSwitcherVisible = false + navigationExpanded = false + showProfiles = true + }, + onDismiss = { + userSwitcherVisible = false + scope.launch { + kotlinx.coroutines.delay(16L) + runCatching { navigationFocusRequester.requestFocus() } + } + }, + ) + } if (showSettings) { - BackHandler(onBack = { showSettings = false }) - SettingsSheet(editableServer = true, onClose = { showSettings = false }) + val closeSettings: () -> Unit = { + showSettings = false + scope.launch { + kotlinx.coroutines.delay(16L) + if (restoreRailAfterSettings) { + navigationExpanded = true + runCatching { navigationFocusRequester.requestFocus() } + restoreRailAfterSettings = false + } else if (returnItemId != null) { + runCatching { cardReturnFocusRequester.requestFocus() } + } else { + runCatching { contentFocusRequester.requestFocus() } + } + } + } + BackHandler(onBack = closeSettings) + SettingsSheet( + onClose = closeSettings, + overlay = false, + ) } if (showProfiles) { BackHandler(onBack = { showProfiles = false }) @@ -1149,6 +1904,7 @@ private fun HomeScreen( profiles = settings.profiles, currentProfileId = settings.activeProfileId, switchingProfileId = switchingProfileId, + removingProfileId = removingProfileId, onSelect = { profile -> if (profile.id == settings.activeProfileId) { showProfiles = false @@ -1160,6 +1916,13 @@ private fun HomeScreen( } } }, + onRemove = { profile -> + removingProfileId = profile.id + scope.launch { + runCatching { repo.removeProfile(profile) } + removingProfileId = null + } + }, onAddProfile = { showProfiles = false onAddProfile() @@ -1169,38 +1932,193 @@ private fun HomeScreen( } detailsItem?.let { selected -> BackHandler { - detailsItem = null - runCatching { cardReturnFocusRequester.requestFocus() } + val previous = detailsTrail.lastOrNull() + if (previous != null) { + detailsTrail = detailsTrail.dropLast(1) + detailsItem = previous + } else { + detailsItem = null + runCatching { cardReturnFocusRequester.requestFocus() } + } } FocusedDetailsOverlay( homeViewModel = homeViewModel, selected = selected, + onOpenItem = { related -> + detailsTrail = detailsTrail + selected + detailsItem = related + }, onPlay = { detailsItem = null + detailsTrail = emptyList() playItem(it) }, onToggleFavorite = homeViewModel::setFavorite, + isMyShow = myShows.any { it.itemId == selected.id }, + onToggleMyShow = { item, saved -> + scope.launch { + if (saved) { + runCatching { repo.saveMyShow(item) }.onSuccess { + myShows = it + Toast.makeText( + context, + "${item.name} added to My Shows", + Toast.LENGTH_SHORT, + ).show() + } + } else { + runCatching { repo.removeMyShow(item.id) }.onSuccess { + myShows = myShows.filterNot { it.itemId == item.id } + } + } + } + }, onTogglePlayed = homeViewModel::setPlayed, onClose = { detailsItem = null + detailsTrail = emptyList() runCatching { cardReturnFocusRequester.requestFocus() } }, ) } + selectedMyShow?.let { show -> + BackHandler { selectedMyShow = null } + MyShowDetailsOverlay( + show = show, + repository = repo, + removing = removingMyShow, + onRemove = { + removingMyShow = true + scope.launch { + runCatching { repo.removeMyShow(show.itemId) }.onSuccess { + myShows = myShows.filterNot { it.itemId == show.itemId } + selectedMyShow = null + } + removingMyShow = false + } + }, + onClose = { selectedMyShow = null }, + ) + } + if (showNotifications) { + BackHandler { showNotifications = false } + NotificationsOverlay( + notifications = notificationState.notifications, + preferences = notificationState.preferences, + onToggleEnabled = { + scope.launch { + val updated = notificationState.preferences.copy( + enabled = !notificationState.preferences.enabled, + ) + runCatching { repo.setNotificationPreferences(updated) } + .onSuccess { notificationState = it } + } + }, + onToggleShowReturns = { + scope.launch { + val updated = notificationState.preferences.copy( + showReturnAlerts = !notificationState.preferences.showReturnAlerts, + ) + runCatching { repo.setNotificationPreferences(updated) } + .onSuccess { notificationState = it } + } + }, + onRead = { notification -> + scope.launch { + runCatching { repo.markNotificationRead(notification.id) }.onSuccess { + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) it.copy(readAt = "now") else it + }, + ) + } + } + }, + onDismissNotification = { notification -> + scope.launch { + runCatching { repo.dismissNotification(notification.id) }.onSuccess { + notificationState = notificationState.copy( + notifications = notificationState.notifications.filterNot { + it.id == notification.id + }, + ) + } + } + }, + onClose = { showNotifications = false }, + ) + } quickMenuItem?.let { selected -> - BackHandler { + val closeQuickActions: () -> Unit = { quickMenuItem = null - runCatching { cardReturnFocusRequester.requestFocus() } + quickMenuRowId = null + scope.launch { + // The overlay owns focus until it leaves composition. Restore the + // exact originating card after its focus node is available again. + kotlinx.coroutines.delay(16L) + runCatching { cardReturnFocusRequester.requestFocus() } + } } + BackHandler(onBack = closeQuickActions) FocusedQuickActionsOverlay( homeViewModel = homeViewModel, selected = selected, + onOpenDetails = { + quickMenuItem = null + detailsItem = it + }, onSetFavorite = homeViewModel::setFavorite, onSetPlayed = homeViewModel::setPlayed, - onClose = { - quickMenuItem = null - runCatching { cardReturnFocusRequester.requestFocus() } + rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title, + rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(), + onToggleRowPinned = quickMenuRowId?.let { rowId -> + { + val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet() + if (!pinned.add(rowId)) pinned.remove(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + pinned, + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + closeQuickActions() + } }, + onHideRow = quickMenuRowId?.let { rowId -> + { + val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet() + hidden.add(rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + settings.homeRowOrder.decodeRowIds(), + settings.homePinnedRows.decodeRowIds().toSet(), + hidden, + ) + } + closeQuickActions() + } + }, + onMoveRow = quickMenuRowId?.let { rowId -> + { direction -> + val currentIds = rows.map(HomeBrowseRow::id).toMutableList() + val index = currentIds.indexOf(rowId) + val target = (index + direction).coerceIn(0, currentIds.lastIndex) + if (index >= 0 && target != index) { + currentIds.removeAt(index) + currentIds.add(target, rowId) + scope.launch { + ServiceLocator.settings.setHomeRowPreferences( + currentIds, + settings.homePinnedRows.decodeRowIds().toSet(), + settings.homeHiddenRows.decodeRowIds().toSet(), + ) + } + } + closeQuickActions() + } + }, + onClose = closeQuickActions, ) } launchingItem?.let { item -> @@ -1213,8 +2131,7 @@ private fun HomeScreen( visible = showForYouNudge && selectedDestination == BrowseDestination.HOME && !settings.hasOpenedForYou && - liveMaintenance == null && - homeState.update?.isMandatory != true, + liveMaintenance == null, username = settings.username, modifier = Modifier.align(Alignment.TopCenter), ) @@ -1224,20 +2141,11 @@ private fun HomeScreen( // The alert itself is collected inside the banner, so an arriving one does // not recompose this whole function. Only the suppression conditions — both // already read here for other reasons — cross the boundary. - suppressed = liveMaintenance != null || homeState.update?.isMandatory == true, + suppressed = liveMaintenance != null, // Flush to the top edge and spanning the rail: for its few seconds this is // the top layer of the screen, the way a broadcast notice is. modifier = Modifier.align(Alignment.TopCenter), ) - // Last in the Box, so it draws over everything — including the rail and any - // overlay that happened to be open when the check came back. - homeState.update?.takeIf { liveMaintenance == null }?.let { update -> - UpdateScreen( - update = update, - onDismiss = homeViewModel::dismissUpdatePrompt, - modifier = Modifier.zIndex(10f), - ) - } } } @@ -1296,11 +2204,13 @@ private fun RecentSearchesRow( onContentFocused: () -> Unit, onQuerySelected: (String) -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically, ) { + HomeRowHeaderIcon(Icons.Default.Search) + Spacer(Modifier.width(HomeRowHeaderIconGap)) Text( "Recent searches", color = Color(0xFFF1F3F4), @@ -1316,7 +2226,11 @@ private fun RecentSearchesRow( ) } LazyRow( - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 36.dp), + // Vertical padding so a focus-scaled chip has room to grow, like every other row. + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 36.dp, + vertical = 9.dp, + ), horizontalArrangement = Arrangement.spacedBy(9.dp), ) { items(queries, key = { it.lowercase() }) { query -> @@ -1326,7 +2240,7 @@ private fun RecentSearchesRow( onClick = { onQuerySelected(query) }, contentDescription = "Search again for $query", modifier = Modifier - .clip(RoundedCornerShape(999.dp)) + .clip(RoundedCornerShape(MembyChipCorner)) .then( if (first) { Modifier.focusProperties { left = navigationFocusRequester } @@ -1353,11 +2267,6 @@ private fun RecentSearchesRow( } } -internal fun loginWelcomeMessage(username: String): String { - val name = username.trim().ifBlank { "there" } - return "You're now logged in as $name. Welcome to Memby!" -} - @Composable private fun HomeClock(modifier: Modifier = Modifier) { val context = LocalContext.current @@ -1395,9 +2304,22 @@ private fun HomeArtworkPreloader( val density = LocalDensity.current val repo = ServiceLocator.repository val discovered = remember(rows) { - rows.flatMap { row -> row.items.map { row.kind to it } } + // Warm the leading posters across several rows instead of exhausting the + // budget on the first shelf. Vertical navigation is then far less likely to + // compete with image fetch/decode as the next row enters the viewport. + val perRow = rows.map { row -> + row.items.take(4).map { row.kind to it } + } + buildList { + val depth = perRow.maxOfOrNull { it.size } ?: 0 + for (itemIndex in 0 until depth) { + perRow.forEach { candidates -> + candidates.getOrNull(itemIndex)?.let(::add) + } + } + } .distinctBy { it.second.id } - .take(14) + .take(24) } val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } LaunchedEffect(signature, availableWidth) { @@ -1442,20 +2364,17 @@ private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { private fun FocusedHomeMetadata( homeViewModel: HomeViewModel, sectionLabel: String, - navigationFocusRequester: FocusRequester, - onPlay: (BaseItem) -> Unit, - onContentFocused: () -> Unit, modifier: Modifier = Modifier, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() - val homeState by homeViewModel.state.collectAsStateWithLifecycle() + // Only the loading flags matter here, so take the content projection rather than the + // whole state: this panel sits beside the hero and redraws on every focus change as + // it is. + val homeContent by homeViewModel.content.collectAsStateWithLifecycle() MediaMetadataPanel( item = focusedItem, - loading = homeState.loading.isNotEmpty(), + loading = homeContent.loading.isNotEmpty(), sectionLabel = sectionLabel, - navigationFocusRequester = navigationFocusRequester, - onPlay = onPlay, - onContentFocused = onContentFocused, modifier = modifier, ) } @@ -1466,8 +2385,11 @@ private fun FocusedDetailsOverlay( selected: BaseItem, onPlay: (BaseItem) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit, + isMyShow: Boolean, + onToggleMyShow: (BaseItem, Boolean) -> Unit, onTogglePlayed: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, + onOpenItem: (BaseItem) -> Unit, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val item = focusedItem?.takeIf { it.id == selected.id } ?: selected @@ -1476,7 +2398,10 @@ private fun FocusedDetailsOverlay( item = item, onPlay = onPlay, onToggleFavorite = onToggleFavorite, + isMyShow = isMyShow, + onToggleMyShow = onToggleMyShow, onClose = onClose, + onOpenItem = onOpenItem, ) } else { MediaDetailsOverlay( @@ -1485,6 +2410,7 @@ private fun FocusedDetailsOverlay( onToggleFavorite = onToggleFavorite, onTogglePlayed = onTogglePlayed, onClose = onClose, + onOpenItem = onOpenItem, ) } } @@ -1493,15 +2419,27 @@ private fun FocusedDetailsOverlay( private fun FocusedQuickActionsOverlay( homeViewModel: HomeViewModel, selected: BaseItem, + onOpenDetails: (BaseItem) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, + rowTitle: String?, + rowPinned: Boolean, + onToggleRowPinned: (() -> Unit)?, + onHideRow: (() -> Unit)?, + onMoveRow: ((Int) -> Unit)?, onClose: () -> Unit, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() MediaQuickActionsOverlay( item = focusedItem?.takeIf { it.id == selected.id } ?: selected, + onOpenDetails = onOpenDetails, onSetFavorite = onSetFavorite, onSetPlayed = onSetPlayed, + rowTitle = rowTitle, + rowPinned = rowPinned, + onToggleRowPinned = onToggleRowPinned, + onHideRow = onHideRow, + onMoveRow = onMoveRow, onClose = onClose, ) } @@ -1518,7 +2456,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List when (row.kind) { "continue", "nextup" -> "continue" in enabledSections @@ -1542,6 +2480,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List MediaRowKind.FAVORITES "shows" -> MediaRowKind.SHOWS "schedule" -> MediaRowKind.SHOWS + "movie-schedule" -> MediaRowKind.MOVIES // Recommendation strips, "latest", and any kind a future server // sends get poster cards, which suit mixed movie/series rows. else -> MediaRowKind.MOVIES @@ -1552,12 +2491,37 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List "You're all caught up" "favorites" -> "Your favourites will appear here" "latest" -> "No recent movies found" - "schedule" -> "No monitored shows are airing today" + "schedule" -> "No monitored shows are airing in the next 5 days" + "movie-schedule" -> "No monitored movies have a digital release in the next 5 days" else -> "Nothing to show here yet" }, showSecondaryMetadata = settings.showHomeCardMetadata, ) } + return applyHomeRowPreferences(mapped, settings) +} + +internal fun String.decodeRowIds(): List = + lineSequence().map(String::trim).filter(String::isNotEmpty).distinct().toList() + +internal fun applyHomeRowPreferences( + rows: List, + settings: Settings, +): List { + val hidden = settings.homeHiddenRows.decodeRowIds().toSet() + val pinned = settings.homePinnedRows.decodeRowIds().toSet() + val order = settings.homeRowOrder.decodeRowIds().withIndex().associate { it.value to it.index } + return rows + .filterNot { it.id in hidden } + .withIndex() + .sortedWith( + compareBy>( + { if (it.value.id in pinned) 0 else 1 }, + { order[it.value.id] ?: Int.MAX_VALUE }, + { it.index }, + ), + ) + .map(IndexedValue::value) } internal fun homeRowsFor( @@ -1601,11 +2565,15 @@ internal fun homeRowsFor( emptyMessage = "Your favourites will appear here", showSecondaryMetadata = settings.showHomeCardMetadata, ) - return when (destination) { + val destinationRows = when (destination) { // The gateway composes the home screen — including rows this app has no concept // of, like "Because you watched …" — so when it sends rows, they win. BrowseDestination.HOME -> if (state.rows.isNotEmpty()) { serverHomeRows(state, settings) + // Genre/studio shelves have dedicated Movies and TV destinations. Home + // keeps personalised, current and new-release rows so each screen has a + // genuinely different browsing character. + .filterNot { it.id.startsWith("curated:") } } else { settings.homeSections .split(',') @@ -1660,6 +2628,64 @@ internal fun homeRowsFor( BrowseDestination.PROFILES -> emptyList() BrowseDestination.SETTINGS -> emptyList() } + val deduplicated = deduplicateBrowseRows(destinationRows) + return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) { + applyHomeRowPreferences(deduplicated, settings) + } else { + deduplicated + } +} + +/** + * A card gets one place on a screen. Episodes collapse to their parent series so a show + * in Continue Watching cannot immediately reappear as a genre recommendation, and live + * UserData is a final guard against a stale recommendation cache showing something seen. + */ +internal fun deduplicateBrowseRows(rows: List): List { + val used = mutableSetOf() + return rows.map { row -> + val discovery = row.id.startsWith("curated:") || + row.id.startsWith("similar:") || + row.id.startsWith("for-you:") || + row.id == "recommended" + val items = row.items.filter { item -> + if (discovery && ( + item.userData?.played == true || + (item.userData?.playbackPositionTicks ?: 0L) > 0L + ) + ) { + return@filter false + } + used.add(browseDeduplicationKey(item)) + } + row.copy(items = items) + } +} + +/** Applies the opt-in watched-content policy after every destination has built its rows. */ +internal fun applyWatchedVisibility( + rows: List, + enabled: Boolean, +): List = rows.map { row -> + row.copy( + items = visibleWithWatchedPreference(row.items, enabled), + showWatchedEpisodeCount = enabled, + ) +} + +internal fun visibleWithWatchedPreference( + items: List, + enabled: Boolean, +): List = if (enabled) { + items.filterNot { item -> item.isMovie && item.userData?.played == true } +} else { + items +} + +private fun browseDeduplicationKey(item: BaseItem): String { + item.seriesId?.trim()?.takeIf(String::isNotEmpty)?.let { return "series:$it" } + if (item.isSeries) return "series:${item.id}" + return "item:${item.id}" } internal fun forYouBrowseRows( @@ -1712,11 +2738,11 @@ private fun ForYouTimeBudget( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { listOf(30 to "30 min", 60 to "1 hour", 120 to "2 hours", 0 to "Any length") .forEach { (minutes, label) -> - Button( + MembyChoiceChip( + label = label, + selected = minutes == selectedMinutes, onClick = { if (!loading && minutes != selectedMinutes) onSelected(minutes) }, - ) { - Text(if (minutes == selectedMinutes) "✓ $label" else label) - } + ) } } error?.let { @@ -1850,279 +2876,6 @@ private fun HeaderAction(label: String, onClick: () -> Unit) { ) } -private data class HomeRowData( - val title: String, - val items: List, - val emptyMessage: String, - val loading: Boolean, - val showResume: Boolean = false, -) - -@Composable -private fun HomeHero(item: BaseItem) { - val repo = ServiceLocator.repository - val context = LocalContext.current - val backdrop = repo.backdropUrl(item, maxWidth = 1920) ?: repo.primaryUrl(item, maxWidth = 1200) - Box(Modifier.fillMaxWidth().height(530.dp)) { - backdrop?.let { - AsyncImage( - model = ImageRequest.Builder(context) - .data(it) - .crossfade(220) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - Box( - Modifier.fillMaxSize().background( - Brush.verticalGradient( - 0f to Color(0x330A0A0A), - 0.55f to Color(0x220A0A0A), - 1f to Color(0xFF0A0A0A), - ), - ), - ) - Box( - Modifier.fillMaxSize().background( - Brush.horizontalGradient( - 0f to Color(0xE60A0A0A), - 0.48f to Color(0x770A0A0A), - 1f to Color.Transparent, - ), - ), - ) - } -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) -@Composable -private fun HomeRow( - rowKey: String, - title: String, - items: List, - emptyMessage: String, - loading: Boolean, - density: String, - showMetadata: Boolean, - showResume: Boolean, - onPlay: (BaseItem) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title, color = Color(0xFFF0F2F3), fontSize = 20.sp, fontWeight = FontWeight.SemiBold) - } - if (items.isEmpty() && loading) { - Box(Modifier.padding(horizontal = 48.dp)) { HomeRowSkeleton() } - } else if (items.isEmpty()) { - Text(emptyMessage, color = Color(0xFF8F969D), fontSize = 14.sp, modifier = Modifier.padding(horizontal = 48.dp)) - } else { - val rowState = rememberSaveable(rowKey, saver = LazyListState.Saver) { LazyListState() } - LazyRow( - state = rowState, - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 48.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth().focusGroup(), - ) { - items(items, key = { it.id }, contentType = { "content-card" }) { item -> - ContentCard( - item = item, - density = density, - showMetadata = showMetadata, - showResume = showResume, - onClick = { onPlay(item) }, - ) - } - } - } -} - -@Composable -private fun HomeRowSkeleton() { - val transition = rememberInfiniteTransition(label = "home-loading") - val alpha by transition.animateFloat( - initialValue = 0.22f, - targetValue = 0.52f, - animationSpec = infiniteRepeatable(tween(850), RepeatMode.Reverse), - label = "home-loading-alpha", - ) - // Several of these are on screen at once while rows load; reading the pulse in the - // draw lambda keeps that from being a per-frame recomposition each. - Box( - Modifier.width(300.dp).height(169.dp).drawBehind { - drawRoundRect( - color = Color(0xFF30353A).copy(alpha = alpha), - cornerRadius = CornerRadius(8.dp.toPx()), - ) - }, - ) -} - -@Composable -private fun ContentCard( - item: BaseItem, - density: String, - showMetadata: Boolean, - showResume: Boolean, - onClick: () -> Unit, -) { - val repo = ServiceLocator.repository - val watchedTicks = item.userData?.playbackPositionTicks ?: 0L - val totalTicks = item.runTimeTicks ?: 0L - val progress = if (totalTicks > 0L) (watchedTicks.toFloat() / totalTicks).coerceIn(0f, 1f) else 0f - - val screenWidth = LocalConfiguration.current.screenWidthDp.dp - // Five cards remain visible across a 16:9 TV, independent of physical screen size. - val cardsAcross = when (density) { "compact" -> 6; "large" -> 4; else -> 5 } - val cardWidth = ((screenWidth - 96.dp - 16.dp * (cardsAcross - 1)) / cardsAcross) - .coerceIn(150.dp, 360.dp) - val cardHeight = cardWidth * 0.56f - val densityScale = LocalDensity.current - val artworkWidthPx = with(densityScale) { cardWidth.roundToPx() }.coerceIn(320, 720) - val artwork = remember(item.id, artworkWidthPx) { - repo.backdropUrl(item, maxWidth = artworkWidthPx) - ?: repo.primaryUrl(item, maxWidth = artworkWidthPx) - } - var focused by remember { mutableStateOf(false) } - val focusScale by animateFloatAsState( - targetValue = if (focused) 1.035f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "content-card-focus", - ) - val focusAlpha by animateFloatAsState( - targetValue = if (focused) 1f else 0f, - animationSpec = tween(90), - label = "content-card-outline", - ) - Box( - modifier = Modifier - .width(cardWidth) - .zIndex(if (focused) 1f else 0f) - .graphicsLayer { - scaleX = focusScale - scaleY = focusScale - } - .onFocusChanged { focused = it.isFocused } - .clickable(onClick = onClick), - ) { - Column { - Box( - Modifier - .width(cardWidth) - .height(cardHeight) - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF171A1D)) - // The focus ring is drawn rather than set as a border colour: read - // an animated alpha in the modifier chain and every D-pad move - // recomposes this whole card — artwork, badges and all — sixty times - // a second. Read inside the draw lambda and it is a repaint. - .drawWithContent { - drawContent() - val alpha = focusAlpha - if (alpha > 0.01f) { - val stroke = 3.dp.toPx() - drawRoundRect( - color = Color.White.copy(alpha = alpha), - topLeft = Offset(stroke / 2f, stroke / 2f), - size = Size(size.width - stroke, size.height - stroke), - cornerRadius = CornerRadius(8.dp.toPx()), - style = Stroke(width = stroke), - ) - } - }, - ) { - artwork?.let { - AsyncImage( - model = it, - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - if (showResume && progress > 0f) { - Box( - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(6.dp) - .background(Color(0xCC4B535B)), - ) { - Box( - Modifier - .fillMaxWidth(progress) - .height(6.dp) - .background(Color(0xFF52B54B)), - ) - } - } - } - Text( - item.name, - color = if (focused) Color.White else Color(0xFFD9DDE0), - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 9.dp), - ) - if (showMetadata) { - val secondaryText = buildString { - if (showResume && watchedTicks > 0L) { - append("Resume at ").append(formatPlaybackPosition(watchedTicks)) - } else if (showResume) { - append("Up next") - item.seriesName?.takeIf { it.isNotBlank() }?.let { append(" • ").append(it) } - } else { - item.productionYear?.let { append(it) } - item.runtimeMinutes?.let { - if (isNotEmpty()) append(" · ") - append(formatRuntime(it)) - } - } - }.ifBlank { - when { - item.isEpisode -> item.seriesName.orEmpty() - item.isSeries -> "Series" - item.isMovie -> "Movie" - else -> item.type - } - } - Text( - secondaryText, - color = if (focused) Color(0xFFBFC5CA) else Color(0xFF858D94), - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 3.dp, bottom = 8.dp), - ) - } else { - Spacer(Modifier.height(8.dp)) - } - } - } -} - -private fun formatPlaybackPosition(ticks: Long): String { - val totalMinutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0) - val hours = totalMinutes / 60 - val minutes = totalMinutes % 60 - return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" -} - -private fun formatRuntime(minutes: Int): String { - val hours = minutes / 60 - val remainingMinutes = minutes % 60 - return if (hours > 0 && remainingMinutes > 0) "${hours}h ${remainingMinutes}m" - else if (hours > 0) "${hours}h" - else "${remainingMinutes}m" -} - @Composable private fun SettingsPanel(settings: Settings, onBack: () -> Unit) { val context = LocalContext.current @@ -2313,6 +3066,7 @@ private fun TvTextField( isPassword: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, focusRequester: FocusRequester? = null, + downFocusRequester: FocusRequester? = null, onNext: (() -> Unit)? = null, onDone: (() -> Unit)? = null, ) { @@ -2361,6 +3115,13 @@ private fun TvTextField( ), modifier = Modifier .fillMaxWidth() + .then( + if (downFocusRequester != null) { + Modifier.focusProperties { down = downFocusRequester } + } else { + Modifier + }, + ) .then( if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt new file mode 100644 index 0000000..f2c1b8a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -0,0 +1,297 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.FirstPage +import androidx.compose.material.icons.filled.Movie +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.RelatedContent +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.detail.DetailTab +import com.ponzischeme89.memby.ui.detail.DetailZone +import com.ponzischeme89.memby.ui.detail.creditRows +import com.ponzischeme89.memby.ui.detail.detailPositions +import com.ponzischeme89.memby.ui.detail.detailTab +import com.ponzischeme89.memby.ui.detail.detailTabs +import com.ponzischeme89.memby.ui.detail.franchiseStart +import com.ponzischeme89.memby.ui.detail.heroFacts +import com.ponzischeme89.memby.ui.detail.playbackProgress +import com.ponzischeme89.memby.ui.detail.primaryActionLabel +import com.ponzischeme89.memby.ui.detail.remainingLabel +import com.ponzischeme89.memby.ui.detail.technicalSpecs +import kotlinx.coroutines.delay + +/** + * A movie, or any single playable item that is not a series. + * + * Everything structural lives in [DetailPageScaffold]; this file only decides which tabs a + * movie has and what goes in each one. + */ +@Composable +fun MediaDetailsOverlay( + item: BaseItem, + onPlay: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + onTogglePlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, + onOpenItem: (BaseItem) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + var related by remember(item.id) { mutableStateOf(null) } + var trailer by remember(item.id) { mutableStateOf(null) } + LaunchedEffect(item.id) { + related = ServiceLocator.repository.getRelated(item) + } + LaunchedEffect(item.id) { + trailer = ServiceLocator.repository.getLocalTrailer(item.id) + } + MediaDetailContent( + item = item, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + onTogglePlayed = onTogglePlayed, + onOpenItem = onOpenItem, + related = related, + trailer = trailer, + hideWatchedMovies = settings.hideWatchedMovies, + modifier = modifier, + ) +} + +/** + * The layout, with everything it renders as a parameter — so it can be screenshotted and + * previewed without a repository behind it. [related] is null while it is still coming. + */ +@Composable +internal fun MediaDetailContent( + item: BaseItem, + onPlay: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + onTogglePlayed: (BaseItem, Boolean) -> Unit, + modifier: Modifier = Modifier, + related: RelatedContent? = null, + trailer: BaseItem? = null, + hideWatchedMovies: Boolean = false, + onOpenItem: (BaseItem) -> Unit = {}, +) { + val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) } + val credits = remember(item.id, item.people, item.genres) { creditRows(item) } + val visibleRelated = remember(related, hideWatchedMovies) { + visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies) + } + val badges = remember(item.id, item.mediaStreams) { mediaBadges(item) } + val franchise = remember(item.id, item.collectionName, related?.items) { + franchiseStart(item, related?.items.orEmpty()) + } + val tabs = remember(item.id) { detailTabs(isSeries = false) } + // The item arrives with whatever a home row asked for and is replaced by the full + // record moments later. Panes say "loading" rather than "nothing here" until then. + val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty() + + val remembered = remember(item.id) { detailPositions.get(item.id) } + // Every newly opened title starts as a complete hero frame. Remembering a content + // tab also restored its focus and caused the page to reopen below the artwork. + var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) } + val selectedTab = detailTab(tabKey, tabs) + + val play = remember(item.id) { FocusRequester() } + val tabStrip = remember(item.id) { FocusRequester() } + // One requester per pane. Sharing a single "information pane" requester between the + // Overview and Cast & Details panes attached it to two nodes at once for the 80ms + // AnimatedContent spends fading the outgoing pane out, and a Down press landing in that + // window could request focus on the pane that is disappearing. + val overviewPane = remember(item.id) { FocusRequester() } + val castPane = remember(item.id) { FocusRequester() } + val firstRelated = remember(item.id) { FocusRequester() } + val relatedListState = rememberLazyListState(remembered.relatedIndex) + val contentEntry = when (selectedTab) { + DetailTab.MORE_LIKE_THIS -> firstRelated + DetailTab.CAST_DETAILS -> castPane + else -> overviewPane + } + + DetailPositionMemory( + itemId = item.id, + tabKey = tabKey, + relatedIndex = { relatedListState.firstVisibleItemIndex }, + ) + RestoreDetailFocus( + itemId = item.id, + zone = DetailZone.PLAY, + play = play, + tabStrip = tabStrip, + related = firstRelated, + relatedReady = visibleRelated.isNotEmpty(), + content = contentEntry, + contentReady = true, + ) + + var confirmation by remember(item.id) { mutableStateOf(null) } + LaunchedEffect(confirmation) { + if (confirmation != null) { + delay(1_800L) + confirmation = null + } + } + + DetailPageScaffold( + item = item, + facts = heroFacts(item), + badges = badges + listOfNotNull(airingBadgeLabel(item)), + tabs = tabs, + selectedTab = selectedTab, + onSelectTab = { tabKey = it.key }, + playLabel = primaryActionLabel(item), + onPlay = { onPlay(item) }, + playFocusRequester = play, + tabFocusRequester = tabStrip, + contentFocusRequester = contentEntry, + modifier = modifier, + progress = playbackProgress(item), + progressLabel = remainingLabel(item), + reasons = related?.reasons?.takeIf(List::isNotEmpty) + ?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)), + confirmation = confirmation, + onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } }, + heroActions = buildList { + add( + DetailHeroAction( + icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add, + description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites", + active = item.isFavorite, + onClick = { + val desired = !item.isFavorite + onToggleFavorite(item, desired) + confirmation = if (desired) "Added to Favourites" else "Removed from Favourites" + }, + ), + ) + trailer?.let { + add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) })) + } + add( + DetailHeroAction( + icon = Icons.Default.DoneAll, + description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", + active = item.userData?.played == true, + onClick = { onTogglePlayed(item, item.userData?.played != true) }, + ), + ) + // Appended because related content arrives asynchronously. Existing action + // indices (and therefore their attached FocusRequesters) must not move. + franchise?.takeIf { it.firstMovie.id != item.id }?.let { start -> + add( + DetailHeroAction( + icon = Icons.Default.FirstPage, + description = "Open the first ${start.name} movie, ${start.firstMovie.name}", + label = "Start with ${start.firstMovie.name}", + onClick = { onOpenItem(start.firstMovie) }, + ), + ) + } + }, + ) { visibleTab -> + when (visibleTab) { + DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane) + DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane( + items = visibleRelated, + loading = related == null, + onSelect = onOpenItem, + firstFocusRequester = firstRelated, + listState = relatedListState, + ) + DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane( + item = item, + credits = credits, + specs = specs, + detailsLoaded = detailsLoaded, + focusRequester = castPane, + ) + DetailTab.EPISODES -> Unit + } + } +} + +/** + * Writes the page's position back to [detailPositions] as it changes. + * + * Kept out of the pages themselves because both need exactly this and getting it slightly + * different in two places is how "it remembered last time" bugs start. The zone is written + * by the scaffold's focus callbacks; this covers the two things focus does not report. + */ +@Composable +internal fun DetailPositionMemory( + itemId: String, + tabKey: String, + season: Int? = null, + episodeIndex: () -> Int = { 0 }, + relatedIndex: () -> Int = { 0 }, +) { + LaunchedEffect(itemId, tabKey, season) { + detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) } + } + LaunchedEffect(itemId) { + snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) -> + detailPositions.update(itemId) { + it.copy(episodeIndex = episode, relatedIndex = relatedCard) + } + } + } +} + +/** + * Puts focus back where the viewer left it. + * + * Play is focused first regardless: it exists on the first frame, so the remote is live + * while the rest of the page is still arriving. Only then does focus move on to the band + * the viewer was actually in — and only if that band has something to land on, because a + * request against an unplaced [FocusRequester] throws. + */ +@Composable +internal fun RestoreDetailFocus( + itemId: String, + zone: DetailZone, + play: FocusRequester, + tabStrip: FocusRequester, + related: FocusRequester, + relatedReady: Boolean, + content: FocusRequester? = null, + contentReady: Boolean = false, +) { + LaunchedEffect(itemId) { + delay(32L) + runCatching { play.requestFocus() } + } + // One shot. The readiness flags flip when the network lands, and re-running then would + // haul focus out from under a viewer who has already started moving around the page. + var restored by remember(itemId) { mutableStateOf(zone == DetailZone.PLAY) } + LaunchedEffect(itemId, relatedReady, contentReady) { + if (restored) return@LaunchedEffect + val target = when (zone) { + DetailZone.PLAY -> null + DetailZone.TABS -> tabStrip + DetailZone.CONTENT -> content?.takeIf { contentReady } + DetailZone.RELATED -> related.takeIf { relatedReady } + } ?: return@LaunchedEffect + // After Play, so the restore lands second and wins; a frame either way is + // invisible, but the order is not. + delay(96L) + if (runCatching { target.requestFocus() }.isSuccess) restored = true + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt new file mode 100644 index 0000000..5d0169a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -0,0 +1,180 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PlayArrow +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 +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembyHairline +import com.ponzischeme89.memby.ui.theme.MembyMutedText + +/** + * One green Play button, in two sizes. + * + * The app had three button languages: this one, a hand-rolled copy in the home hero with + * the same look and different metrics, and raw `androidx.tv.material3.Button`s carrying + * glyphs in their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours nothing + * else on those screens uses. A remote user learns one shape and one green; drawing it + * three ways teaches them nothing and makes every metric a separate decision. + * + * [MembyPlayChip] is the same surface without focus behaviour when the whole hero card is + * already the focusable node. + */ +@Composable +internal fun MembyPlayButton( + label: String, + onClick: () -> Unit, + onFocused: () -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus") + PrimaryActionSurface( + label = label, + icon = Icons.Default.PlayArrow, + focused = focused, + compact = compact, + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } + .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() } + .clickable(onClick = onClick), + ) +} + +@Composable +internal fun MembyPlayChip( + label: String, + focused: Boolean, + modifier: Modifier = Modifier, + compact: Boolean = true, +) { + PrimaryActionSurface( + label = label, + icon = Icons.Default.PlayArrow, + focused = focused, + compact = compact, + modifier = modifier, + ) +} + +@Composable +private fun PrimaryActionSurface( + label: String, + icon: ImageVector, + focused: Boolean, + compact: Boolean, + modifier: Modifier, +) { + val shape = RoundedCornerShape(MembyCardCorner) + Row( + modifier = modifier + .shadow(if (focused) 18.dp else 7.dp, shape) + .clip(shape) + .background(MembyAccent) + .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyAccent, shape) + .padding( + horizontal = if (compact) 14.dp else 23.dp, + vertical = if (compact) 7.dp else 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(if (compact) 18.dp else 23.dp), + ) + Spacer(Modifier.width(if (compact) 6.dp else 8.dp)) + Text( + label, + color = Color.White, + fontSize = if (compact) 13.sp else 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +/** + * A selectable chip for a small set of mutually exclusive choices. The tick is a real icon + * on the selected chip rather than a character in the label, so the chip does not change + * width when the choice moves. + */ +@Composable +internal fun MembyChoiceChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(MembyChipCorner) + Row( + modifier = modifier + .clip(shape) + .background( + when { + focused -> Color.White + selected -> MembyAccent + else -> Color.White.copy(alpha = 0.07f) + }, + ) + .border(1.dp, if (selected || focused) Color.Transparent else MembyHairline, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = if (focused) Color.Black else Color.White, + modifier = Modifier.size(15.dp), + ) + Spacer(Modifier.width(6.dp)) + } + Text( + label, + color = when { + focused -> Color.Black + selected -> Color.White + else -> MembyMutedText + }, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt new file mode 100644 index 0000000..361a795 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -0,0 +1,442 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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 +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +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 +import androidx.compose.ui.unit.Dp +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 + +@Composable +internal fun MyShowsStrip( + shows: List, + repository: EmbyRepository, + availableWidth: Dp, + density: String, + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester?, + onShowSelected: (MyShow) -> Unit, + onContentFocused: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeRowHeaderIcon(Icons.Default.Bookmark) + Spacer(Modifier.width(HomeRowHeaderIconGap)) + Text( + "My Shows", + color = Color(0xFFF1F3F4), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + if (shows.isNotEmpty()) { + Text( + shows.size.toString(), + color = Color(0xFFAEB7BF), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(start = 10.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + if (shows.isEmpty()) { + Text( + "Open any series and choose “Add to My Shows”.", + color = Color(0xFFAEB7BF), + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), + ) + } else { + val cardsAcross = when (density) { + "compact" -> 8 + "large" -> 5 + else -> 7 + } + val cardWidth = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp) + LazyRow( + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(shows, key = MyShow::itemId) { show -> + MyShowCard( + show = show, + repository = repository, + width = cardWidth, + onClick = { onShowSelected(show) }, + modifier = Modifier + .then( + if (show.itemId == shows.first().itemId && contentFocusRequester != null) { + Modifier.focusRequester(contentFocusRequester) + } else { + Modifier + }, + ) + .focusProperties { left = navigationFocusRequester } + .onFocusChanged { if (it.hasFocus) onContentFocused() }, + ) + } + } + } + } +} + +@Composable +private fun MyShowCard( + show: MyShow, + repository: EmbyRepository, + width: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FocusScaleContainer( + onFocused = {}, + onClick = onClick, + contentDescription = "${show.title}, ${myShowCardSubtitle(show)}", + modifier = modifier.width(width), + ) { focused -> + Column { + Box( + modifier = Modifier + .width(width) + .aspectRatio(2f / 3f) + .shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp)) + .clip(RoundedCornerShape(9.dp)) + .background(Color(0xFF20252A)) + .border( + 2.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.07f), + RoundedCornerShape(9.dp), + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = repository.myShowImageUrl(show.itemId, show.imageTag), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + myShowBadge(show)?.let { (label, color) -> + Text( + label, + color = Color(0xFF090B0D), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color) + .padding(horizontal = 7.dp, vertical = 4.dp), + ) + } + } + Text( + show.title, + color = if (focused) Color.White else Color(0xFFE1E5E8), + fontSize = 14.sp, + fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), + ) + Text( + myShowCardSubtitle(show), + color = Color(0xFFAEB7BF), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), + ) + } + } +} + +private fun myShowBadge(show: MyShow): Pair? = when { + !show.nextEpisode.isNullOrBlank() -> "UPCOMING" to Color(0xFF52B54B) + show.sonarrStatus == "Not monitored" -> "UNMONITORED" to Color(0xFFFFB454) + show.lifecycle == "Cancelled" -> "CANCELLED" to Color(0xFFAEB7BF) + else -> null +} + +internal fun myShowCardSubtitle(show: MyShow): String { + if (!show.nextEpisode.isNullOrBlank()) { + val date = runCatching { + DateTimeFormatter.ofPattern("EEE, d MMM") + .format(Instant.parse(show.nextEpisode).atZone(ZoneId.systemDefault())) + }.getOrNull() + if (date != null) return "Next episode · $date" + } + return when { + show.lifecycle != "Unknown" -> show.lifecycle + show.sonarrStatus != "Not found" -> show.sonarrStatus + else -> "Saved show" + } +} + +@Composable +internal fun MyShowDetailsOverlay( + show: MyShow, + repository: EmbyRepository, + removing: Boolean, + onRemove: () -> Unit, + onClose: () -> Unit, +) { + Box( + Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .fillMaxWidth(0.72f) + .background(Color(0xFF151A1E), RoundedCornerShape(20.dp)) + .padding(34.dp), + horizontalArrangement = Arrangement.spacedBy(30.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = repository.myShowImageUrl(show.itemId, show.imageTag, 500), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .width(170.dp) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF20252A)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) + StatusLine("Sonarr", show.sonarrStatus) + StatusLine("Next episode", formatMyShowDate(show.nextEpisode)) + StatusLine("Series status", show.lifecycle) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onRemove, enabled = !removing) { + Text(if (removing) "Removing…" else "Remove from My Shows") + } + Button(onClick = onClose) { Text("Close") } + } + } + } + } +} + +@Composable +private fun StatusLine(label: String, value: String) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text("$label:", color = Color(0xFFAEB7BF), fontSize = 16.sp) + Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } +} + +@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 { + DateTimeFormatter.ofPattern("EEE, d MMM yyyy · h:mm a") + .format(Instant.parse(value).atZone(ZoneId.systemDefault())) + }.getOrDefault("Not announced") +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/QuickActionsNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/QuickActionsNavigation.kt new file mode 100644 index 0000000..c8c463d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/QuickActionsNavigation.kt @@ -0,0 +1,18 @@ +package com.ponzischeme89.memby.ui + +internal const val QuickActionsHoldDurationMillis = 650L + +internal enum class QuickActionDirection { UP, DOWN } + +internal fun quickActionNextIndex( + currentIndex: Int, + actionCount: Int, + direction: QuickActionDirection, +): Int { + if (actionCount <= 0) return 0 + val current = currentIndex.coerceIn(0, actionCount - 1) + return when (direction) { + QuickActionDirection.UP -> (current - 1).coerceAtLeast(0) + QuickActionDirection.DOWN -> (current + 1).coerceAtMost(actionCount - 1) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RowNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RowNavigation.kt new file mode 100644 index 0000000..bbc12df --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RowNavigation.kt @@ -0,0 +1,44 @@ +package com.ponzischeme89.memby.ui + +internal enum class RowFocusDirection { + UP, + DOWN, +} + +internal data class RowFocusRequest( + val rowId: String, + val itemIndex: Int, + val requestId: Int, +) + +/** + * Finds the next row that can actually receive card focus. Loading and empty rows are + * deliberately skipped so one D-pad press always produces visible movement. + */ +internal fun adjacentFocusableRowIndex( + itemCounts: List, + currentIndex: Int, + direction: RowFocusDirection, +): Int? { + if (currentIndex !in itemCounts.indices) return null + val step = if (direction == RowFocusDirection.DOWN) 1 else -1 + var candidate = currentIndex + step + while (candidate in itemCounts.indices) { + if (itemCounts[candidate] > 0) return candidate + candidate += step + } + return null +} + +/** + * Returning to a row restores the card last used there. On a first visit, preserve the + * viewer's horizontal position as far as the shorter destination row permits. + */ +internal fun rowEntryItemIndex( + sourceIndex: Int, + destinationItemCount: Int, + rememberedDestinationIndex: Int?, +): Int { + if (destinationItemCount <= 0) return 0 + return (rememberedDestinationIndex ?: sourceIndex).coerceIn(0, destinationItemCount - 1) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index dce8efb..26a66e7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -8,19 +8,34 @@ import androidx.compose.foundation.clickable 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight 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.LazyListState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.BookmarkAdded +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,7 +49,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.Brush import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -42,35 +56,57 @@ 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.Button import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.RelatedContent +import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.BaseItem -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import kotlinx.coroutines.delay - -private val SeriesGreen = Color(0xFF52B54B) -private val SeriesMuted = Color(0xFFD0D6DB) -private val SeriesQuiet = Color(0xFFAEB7BF) +import com.ponzischeme89.memby.ui.detail.DetailTab +import com.ponzischeme89.memby.ui.detail.DetailZone +import com.ponzischeme89.memby.ui.detail.availableSeasons +import com.ponzischeme89.memby.ui.detail.creditRows +import com.ponzischeme89.memby.ui.detail.defaultSeason +import com.ponzischeme89.memby.ui.detail.detailPositions +import com.ponzischeme89.memby.ui.detail.detailTab +import com.ponzischeme89.memby.ui.detail.detailTabs +import com.ponzischeme89.memby.ui.detail.episodeHeadline +import com.ponzischeme89.memby.ui.detail.episodesForSeason +import com.ponzischeme89.memby.ui.detail.heroFacts +import com.ponzischeme89.memby.ui.detail.isPlayed +import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch +import com.ponzischeme89.memby.ui.detail.playbackProgress +import com.ponzischeme89.memby.ui.detail.primaryActionLabel +import com.ponzischeme89.memby.ui.detail.remainingLabel +import com.ponzischeme89.memby.ui.detail.seasonLabel +import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator +import com.ponzischeme89.memby.ui.detail.technicalSpecs +import com.ponzischeme89.memby.ui.detail.unwatchedCount +/** + * A series. Loads its episodes and hands them to [SeriesDetailContent], which is where the + * layout lives — everything that decides how the screen looks is a parameter there, so it + * can be previewed and screenshotted without a server behind it. + */ @Composable fun SeriesDetailsOverlay( item: BaseItem, onPlay: (BaseItem) -> Unit, onToggleFavorite: (BaseItem, Boolean) -> Unit, + isMyShow: Boolean, + onToggleMyShow: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, + onOpenItem: (BaseItem) -> Unit = {}, modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository + val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY) var episodes by remember(item.id) { mutableStateOf?>(null) } var loadFailed by remember(item.id) { mutableStateOf(false) } - var selectedSectionKey by rememberSaveable(item.id) { - mutableStateOf(SeriesDetailSection.EPISODES.key) - } - val selectedSection = seriesDetailSection(selectedSectionKey) + var related by remember(item.id) { mutableStateOf(null) } + var trailer by remember(item.id) { mutableStateOf(null) } LaunchedEffect(item.id) { runCatching { repository.getSeriesEpisodes(item.id) } @@ -80,9 +116,50 @@ fun SeriesDetailsOverlay( episodes = emptyList() } } + // Separate from the episode request on purpose: the two are independent, and the page + // must not wait on "more like this" to show a show's own episodes. + LaunchedEffect(item.id) { + related = repository.getRelated(item) + } + LaunchedEffect(item.id) { + trailer = repository.getLocalTrailer(item.id) + } + SeriesDetailContent( + item = item, + episodes = episodes, + loadFailed = loadFailed, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + isMyShow = isMyShow, + onToggleMyShow = onToggleMyShow, + related = related, + trailer = trailer, + hideWatchedMovies = settings.hideWatchedMovies, + onOpenItem = onOpenItem, + modifier = modifier, + ) +} + +/** [episodes] is null while they are still loading, as is [related]. */ +@Composable +internal fun SeriesDetailContent( + item: BaseItem, + episodes: List?, + loadFailed: Boolean, + onPlay: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + isMyShow: Boolean, + onToggleMyShow: (BaseItem, Boolean) -> Unit, + modifier: Modifier = Modifier, + related: RelatedContent? = null, + trailer: BaseItem? = null, + hideWatchedMovies: Boolean = false, + onOpenItem: (BaseItem) -> Unit = {}, +) { + val remembered = remember(item.id) { detailPositions.get(item.id) } val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) } - var selectedSeason by rememberSaveable(item.id) { mutableStateOf(null) } + var selectedSeason by rememberSaveable(item.id) { mutableStateOf(remembered.season) } LaunchedEffect(seasons) { if (selectedSeason !in seasons) { selectedSeason = defaultSeason(episodes.orEmpty()) @@ -91,308 +168,262 @@ fun SeriesDetailsOverlay( val seasonEpisodes = remember(episodes, selectedSeason) { episodesForSeason(episodes.orEmpty(), selectedSeason) } - val firstEpisode = remember { FocusRequester() } - val seasonFocusRequesters = remember(seasons) { - seasons.associateWith { FocusRequester() } + val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) } + val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) } + val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) } + val credits = remember(item.id, item.people, item.genres) { creditRows(item) } + val visibleRelated = remember(related, hideWatchedMovies) { + visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies) } - var initialSeasonFocusRequested by remember(item.id) { mutableStateOf(false) } - LaunchedEffect(selectedSeason, seasons) { - val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect - if (!initialSeasonFocusRequested) { - delay(40) - runCatching { requester.requestFocus() } - initialSeasonFocusRequested = true + + // Fixed on the first frame from what the item *is*. A series has episodes, a cast and + // technical details whether or not they have arrived yet, and a strip that waits for + // the network is a strip that moves under the viewer's thumb. + val tabs = remember(item.id) { detailTabs(isSeries = true) } + val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty() + // A series opens on its cinematic hero and Overview just like a movie. Season and + // rail positions are still remembered once the viewer enters Episodes. + var tabKey by rememberSaveable(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) } + val selectedTab = detailTab(tabKey, tabs) + + val play = remember(item.id) { FocusRequester() } + val tabStrip = remember(item.id) { FocusRequester() } + // One requester per pane: Overview, Cast & Details and the Episodes empty states all + // shared a single "information pane" requester, which left it attached to two live + // nodes while AnimatedContent faded the outgoing one out. + val overviewPane = remember(item.id) { FocusRequester() } + val castPane = remember(item.id) { FocusRequester() } + val episodesPane = remember(item.id) { FocusRequester() } + val firstEpisode = remember(item.id) { FocusRequester() } + val firstRelated = remember(item.id) { FocusRequester() } + val seasonFocusRequesters = remember(seasons) { seasons.associateWith { FocusRequester() } } + val episodeListState = rememberLazyListState(remembered.episodeIndex) + val relatedListState = rememberLazyListState(remembered.relatedIndex) + + // Every one of these targets has to be attached to something on screen *right now*. A + // `focusProperties` pointing at a requester that was never placed throws the moment the + // viewer presses that direction — and while the episode request is in flight, on a + // one-season show, or on any tab other than Episodes, neither the chips nor the cards + // exist. [FocusRequester.Default] hands the press back to ordinary focus search. + val episodesOpen = selectedTab == DetailTab.EPISODES + val seasonStripShown = episodesOpen && seasons.size > 1 + val hasEpisodeCards = episodesOpen && seasonEpisodes.isNotEmpty() + val selectedSeasonChip = + if (seasonStripShown) seasonFocusRequesters[selectedSeason] else null + val episodeEntry = selectedSeasonChip + ?: firstEpisode.takeIf { hasEpisodeCards } + ?: episodesPane + val belowSeasons = if (hasEpisodeCards) firstEpisode else FocusRequester.Default + val aboveEpisodes = selectedSeasonChip ?: tabStrip + val contentEntry = when (selectedTab) { + DetailTab.EPISODES -> episodeEntry + DetailTab.MORE_LIKE_THIS -> firstRelated + DetailTab.CAST_DETAILS -> castPane + else -> overviewPane + } + + DetailPositionMemory( + itemId = item.id, + tabKey = tabKey, + season = selectedSeason, + episodeIndex = { episodeListState.firstVisibleItemIndex }, + relatedIndex = { relatedListState.firstVisibleItemIndex }, + ) + RestoreDetailFocus( + itemId = item.id, + zone = DetailZone.PLAY, + play = play, + tabStrip = tabStrip, + related = firstRelated, + relatedReady = visibleRelated.isNotEmpty(), + content = contentEntry, + contentReady = true, + ) + + var confirmation by remember(item.id) { mutableStateOf(null) } + LaunchedEffect(confirmation) { + if (confirmation != null) { + kotlinx.coroutines.delay(1_800L) + confirmation = null } } - Box( - modifier - .fillMaxSize() - .background(Color(0xFF090B0D)), - ) { - BackdropLayer(item, Modifier.fillMaxSize()) - Box( - Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to Color.Black.copy(alpha = 0.20f), - 0.42f to Color.Black.copy(alpha = 0.52f), - 0.72f to Color(0xF5090B0D), - 1f to Color(0xFF090B0D), - ), + DetailPageScaffold( + item = item, + facts = heroFacts(item, seasons.size), + // A series' streams describe its episodes; when Emby gives them, they are as true + // of the show as they are of a film, and the row this page opened from already + // badges them. + badges = mediaBadges(item), + tabs = tabs, + selectedTab = selectedTab, + onSelectTab = { tabKey = it.key }, + playLabel = primaryActionLabel(item, nextEpisode), + onPlay = { onPlay(nextEpisode ?: item) }, + playFocusRequester = play, + tabFocusRequester = tabStrip, + contentFocusRequester = contentEntry, + modifier = modifier, + progress = nextEpisode?.let(::playbackProgress) ?: 0f, + progressLabel = nextEpisode?.let(::remainingLabel), + reasons = related?.reasons?.takeIf(List::isNotEmpty) + ?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)), + confirmation = confirmation, + onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } }, + heroActions = buildList { + add( + DetailHeroAction( + icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add, + description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites", + active = item.isFavorite, + onClick = { + val desired = !item.isFavorite + onToggleFavorite(item, desired) + confirmation = if (desired) "Added to Favourites" else "Removed from Favourites" + }, ), - ) - Box( - Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - 0f to Color.Black.copy(alpha = 0.72f), - 0.60f to Color.Black.copy(alpha = 0.18f), - 1f to Color.Transparent, - ), - ), - ) - Column( - modifier = Modifier - .fillMaxSize() - .padding(start = 64.dp, end = 52.dp, top = 42.dp, bottom = 34.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(Modifier.fillMaxWidth(0.70f)) { - Text( - "SERIES", - color = SeriesGreen, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - ) - Text( - item.name, - color = Color.White, - fontSize = 36.sp, - lineHeight = 40.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val facts = listOfNotNull( - item.productionYear?.toString(), - seasons.size.takeIf { it > 0 }?.let { count -> - "$count ${if (count == 1) "Season" else "Seasons"}" - }, - item.officialRating, - item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank), - ) - if (facts.isNotEmpty()) { - Text( - facts.joinToString(" • "), - color = SeriesMuted, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - item.overview?.takeIf(String::isNotBlank) ?: "No description available.", - color = SeriesMuted, - fontSize = 15.sp, - lineHeight = 20.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 8.dp), - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) { - Text(if (item.isFavorite) "Remove favourite" else "Add favourite") - } - Button(onClick = onClose) { Text("Close") } - } - } - - Spacer(Modifier.height(15.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - SeriesDetailTabs( - selected = selectedSection, - episodeCount = episodes?.size, - castCount = item.cast.size, - onSelected = { selectedSectionKey = it.key }, - ) - if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) { - Box( - Modifier - .padding(horizontal = 16.dp) - .width(1.dp) - .height(30.dp) - .background(Color.White.copy(alpha = 0.12f)), - ) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(9.dp), - modifier = Modifier.weight(1f), - ) { - items(seasons, key = { it }) { season -> - SeasonChip( - season = season, - selected = season == selectedSeason, - focusRequester = seasonFocusRequesters.getValue(season), - episodeFocusRequester = firstEpisode, - onClick = { selectedSeason = season }, - ) - } - } - } - } - Spacer(Modifier.height(8.dp)) - when { - selectedSection == SeriesDetailSection.CAST -> { - if (item.cast.isEmpty()) { - Text( - "Cast information is not available for this show.", - color = SeriesMuted, - fontSize = 15.sp, - modifier = Modifier.padding(top = 24.dp), - ) - } else { - CastRail( - people = item.cast, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - showTitle = false, - ) - } - } - episodes == null -> { - Text( - "Loading seasons…", - color = SeriesQuiet, - fontSize = 15.sp, - modifier = Modifier.padding(top = 24.dp), - ) - } - loadFailed -> { - Text( - "Episodes are temporarily unavailable.", - color = SeriesMuted, - fontSize = 15.sp, - modifier = Modifier.padding(top = 24.dp), - ) - } - seasons.isEmpty() -> { - Text( - "No episodes are available for this show.", - color = SeriesMuted, - fontSize = 15.sp, - modifier = Modifier.padding(top = 24.dp), - ) - } - else -> { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = 2.dp, - end = 42.dp, - top = 7.dp, - bottom = 8.dp, - ), - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) { - items(seasonEpisodes, key = BaseItem::id) { episode -> - EpisodeCard( - episode = episode, - onClick = { onPlay(episode) }, - seasonFocusRequester = seasonFocusRequesters[selectedSeason] - ?: FocusRequester.Default, - modifier = if (episode.id == seasonEpisodes.first().id) { - Modifier - .focusRequester(firstEpisode) - } else { - Modifier - }, - ) - } - } - } - } - } - } -} - -internal enum class SeriesDetailSection(val key: String) { - EPISODES("episodes"), - CAST("cast"), -} - -internal fun seriesDetailSection(key: String): SeriesDetailSection = - SeriesDetailSection.entries.firstOrNull { it.key == key } ?: SeriesDetailSection.EPISODES - -@Composable -private fun SeriesDetailTabs( - selected: SeriesDetailSection, - episodeCount: Int?, - castCount: Int, - onSelected: (SeriesDetailSection) -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SeriesDetailTab( - label = buildString { - append("Episodes") - episodeCount?.takeIf { it > 0 }?.let { append(" $it") } - }, - selected = selected == SeriesDetailSection.EPISODES, - onClick = { onSelected(SeriesDetailSection.EPISODES) }, - ) - SeriesDetailTab( - label = buildString { - append("Cast") - castCount.takeIf { it > 0 }?.let { append(" $it") } - }, - selected = selected == SeriesDetailSection.CAST, - onClick = { onSelected(SeriesDetailSection.CAST) }, - ) - } -} - -@Composable -private fun SeriesDetailTab( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - var focused by remember { mutableStateOf(false) } - val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - val background = when { - focused -> Color.White - selected -> SeriesGreen.copy(alpha = 0.18f) - else -> Color.White.copy(alpha = 0.055f) - } - val textColor = if (focused) Color.Black else if (selected) Color.White else SeriesQuiet - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clip(shape) - .background(background) - .border( - 1.dp, - when { - focused -> Color.White - selected -> SeriesGreen.copy(alpha = 0.72f) - else -> Color.White.copy(alpha = 0.10f) - }, - shape, ) - .onFocusChanged { focused = it.isFocused } - .clickable(onClick = onClick) - .padding(start = 21.dp, top = 10.dp, end = 21.dp, bottom = 8.dp), - ) { - Text( - label, - color = textColor, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Box( - Modifier - .padding(top = 7.dp) - .width(34.dp) - .height(3.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(2.dp)) - .background(if (selected && !focused) SeriesGreen else Color.Transparent), - ) + if (ServerConfig.isGateway) { + add(DetailHeroAction( + icon = if (isMyShow) Icons.Default.BookmarkAdded else Icons.Default.BookmarkAdd, + description = if (isMyShow) "Remove from My Shows" else "Add to My Shows", + active = isMyShow, + onClick = { onToggleMyShow(item, !isMyShow) }, + )) + } + trailer?.let { + add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) })) + } + }, + ) { visibleTab -> + when (visibleTab) { + DetailTab.OVERVIEW -> DetailOverviewPane( + item = item, + credits = credits, + focusRequester = overviewPane, + // The progress bar carries the position, so this only names the episode. + supportingText = nextEpisode?.let { "Up next ${episodeHeadline(it)}" }, + ) + DetailTab.EPISODES -> EpisodesPane( + episodes = episodes, + seasonEpisodes = seasonEpisodes, + seasons = seasons, + selectedSeason = selectedSeason, + remaining = remaining, + loadFailed = loadFailed, + seasonStripShown = seasonStripShown, + seasonFocusRequesters = seasonFocusRequesters, + firstEpisodeFocusRequester = firstEpisode, + belowSeasons = belowSeasons, + aboveEpisodes = aboveEpisodes, + tabFocusRequester = tabStrip, + listState = episodeListState, + emptyFocusRequester = episodesPane, + onSelectSeason = { selectedSeason = it }, + onPlay = onPlay, + ) + DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane( + items = visibleRelated, + loading = related == null, + onSelect = onOpenItem, + firstFocusRequester = firstRelated, + listState = relatedListState, + ) + DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane( + item = item, + credits = credits, + specs = specs, + detailsLoaded = detailsLoaded, + focusRequester = castPane, + ) + } + } +} + +@Composable +private fun EpisodesPane( + episodes: List?, + seasonEpisodes: List, + seasons: List, + selectedSeason: Int?, + remaining: Int, + loadFailed: Boolean, + seasonStripShown: Boolean, + seasonFocusRequesters: Map, + firstEpisodeFocusRequester: FocusRequester, + belowSeasons: FocusRequester, + aboveEpisodes: FocusRequester, + tabFocusRequester: FocusRequester, + listState: LazyListState, + emptyFocusRequester: FocusRequester, + onSelectSeason: (Int) -> Unit, + onPlay: (BaseItem) -> Unit, +) { + Column(Modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (seasonStripShown) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + items(seasons, key = { it }) { season -> + SeasonChip( + season = season, + selected = season == selectedSeason, + focusRequester = seasonFocusRequesters.getValue(season), + episodeFocusRequester = belowSeasons, + tabFocusRequester = tabFocusRequester, + onClick = { onSelectSeason(season) }, + ) + } + } + } else if (seasons.size == 1) { + Text( + text = seasonLabel(seasons.first()), + color = DetailMutedText, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + if (episodes != null && remaining > 0) { + Text( + text = "$remaining unwatched", + color = DetailQuietText, + fontSize = 12.sp, + maxLines = 1, + modifier = Modifier.padding(start = 14.dp), + ) + } + } + Spacer(Modifier.height(14.dp)) + when { + episodes == null -> DetailFocusablePane(emptyFocusRequester) { Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp) } + loadFailed -> DetailFocusablePane(emptyFocusRequester) { Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp) } + seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { Text("No episodes are available.", color = DetailQuietText, fontSize = 15.sp) } + else -> LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode -> + EpisodeCard( + episode = episode, + onClick = { onPlay(episode) }, + seasonFocusRequester = aboveEpisodes, + isFirst = index == 0, + modifier = if (index == 0) { + Modifier.focusRequester(firstEpisodeFocusRequester) + } else { + Modifier + }, + ) + } + } + } } } @@ -402,81 +433,93 @@ private fun SeasonChip( selected: Boolean, focusRequester: FocusRequester, episodeFocusRequester: FocusRequester, + tabFocusRequester: FocusRequester, onClick: () -> Unit, ) { var focused by remember { mutableStateOf(false) } - val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp) + val shape = RoundedCornerShape(7.dp) Box( modifier = Modifier .clip(shape) .background( when { focused -> Color.White - selected -> SeriesGreen - else -> Color(0xCC20252A) + selected -> DetailAccent + else -> Color.White.copy(alpha = 0.07f) }, ) - .border(1.dp, Color.White.copy(alpha = if (selected) 0.28f else 0.12f), shape) + .border(1.dp, if (selected || focused) Color.Transparent else DetailHairline, shape) .focusRequester(focusRequester) - .focusProperties { down = episodeFocusRequester } + .focusProperties { + up = tabFocusRequester + down = episodeFocusRequester + } .onFocusChanged { focused = it.isFocused } .clickable(onClick = onClick) - .padding(horizontal = 17.dp, vertical = 9.dp), + .padding(horizontal = 14.dp, vertical = 7.dp), ) { Text( - if (season == 0) "Specials" else "Season $season", + text = seasonLabel(season), color = if (focused) Color.Black else Color.White, - fontSize = 14.sp, + fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + maxLines = 1, ) } } +/** A minimal D-pad row: the focused episode becomes the unmistakable selection. */ @Composable private fun EpisodeCard( episode: BaseItem, onClick: () -> Unit, seasonFocusRequester: FocusRequester, + isFirst: Boolean, modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository var focused by remember { mutableStateOf(false) } val imageUrl = remember(episode.id) { - repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360) - } - val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp) - val progress = remember(episode.userData, episode.runTimeTicks) { - val runtime = episode.runTimeTicks ?: 0L - if (runtime > 0L) { - ((episode.userData?.playbackPositionTicks ?: 0L).toFloat() / runtime).coerceIn(0f, 1f) - } else { - 0f - } + repository.primaryUrl(episode, 480) ?: repository.backdropUrl(episode, 480) } + val shape = RoundedCornerShape(8.dp) + val progress = remember(episode.userData, episode.runTimeTicks) { playbackProgress(episode) } val scale by animateFloatAsState( - targetValue = if (focused) 1.045f else 1f, - animationSpec = tween(110), + targetValue = if (focused) 1.01f else 1f, + animationSpec = tween(100), label = "episode-card-focus", ) - Column( + Row( modifier = modifier - .width(252.dp) + .fillMaxWidth() + .height(132.dp) .graphicsLayer { scaleX = scale scaleY = scale + translationY = if (focused) -1f else 0f } .zIndex(if (focused) 1f else 0f) - .focusProperties { up = seasonFocusRequester } + .clip(shape) + .background(if (focused) Color(0xFF23282C) else Color.White.copy(alpha = 0.035f)) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) Color.White.copy(alpha = 0.30f) else Color.White.copy(alpha = 0.08f), + shape = shape, + ) + .focusProperties { + up = if (isFirst) seasonFocusRequester else FocusRequester.Default + } .onFocusChanged { focused = it.isFocused } .clickable(onClick = onClick) - .padding(bottom = 4.dp), + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .clip(shape) - .background(Color(0xFF20252A)), + .width(208.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF15181C)), ) { AsyncImage( model = imageUrl, @@ -488,87 +531,104 @@ private fun EpisodeCard( Modifier .fillMaxSize() .border( - width = if (focused) 3.dp else 1.dp, - color = if (focused) Color.White else Color.White.copy(alpha = 0.10f), - shape = shape, + width = 1.dp, + color = if (focused) Color.White.copy(alpha = 0.28f) else Color.White.copy(alpha = 0.10f), + shape = RoundedCornerShape(6.dp), ), ) + if (focused) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.Black.copy(alpha = 0.76f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play selected episode", + tint = Color.White, + modifier = Modifier.size(25.dp), + ) + } + } + if (episode.isPlayed) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Watched", + tint = DetailAccent, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(16.dp), + ) + } if (progress > 0f) { Box( Modifier .align(Alignment.BottomStart) .fillMaxWidth() - .height(4.dp) + .height(3.dp) .background(Color.Black.copy(alpha = 0.65f)), ) { Box( Modifier .fillMaxWidth(progress) - .height(4.dp) - .background(SeriesGreen), + .height(3.dp) + .background(DetailAccent), ) } } - if (episode.userData?.played == true) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Watched", - tint = SeriesGreen, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(9.dp) - .size(20.dp), - ) - } } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 9.dp), - ) { - Text( - text = listOfNotNull( - episode.indexNumber?.let { "$it." }, - episode.name, - ).joinToString(" "), - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - episode.runtimeMinutes?.let { + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + episode.indexNumber?.let { + Text( + text = "$it.", + color = if (focused) Color(0xFFB7C0C6) else DetailQuietText, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 7.dp), + ) + } Text( - "${it}m", - color = SeriesQuiet, - fontSize = 12.sp, - modifier = Modifier.padding(start = 8.dp), + text = episode.name, + color = if (focused) Color.White else DetailText, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + episode.runtimeMinutes?.let { + Text( + text = "$it min", + color = if (focused) Color(0xFFE4E8EA) else DetailText, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(start = 12.dp) + .clip(RoundedCornerShape(5.dp)) + .background( + if (focused) Color.White.copy(alpha = 0.10f) + else Color.White.copy(alpha = 0.08f), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } } + Spacer(Modifier.height(7.dp)) + Text( + text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.", + color = if (focused) Color(0xFFC4CBD0) else DetailMutedText, + fontSize = 13.sp, + lineHeight = 18.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) } - Text( - episode.overview?.takeIf(String::isNotBlank) ?: "No episode description available.", - color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f), - fontSize = 12.sp, - lineHeight = 16.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 5.dp), - ) + Spacer(Modifier.width(12.dp)) } } - -internal val seriesEpisodeComparator = - compareBy({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name }) - -internal fun availableSeasons(episodes: List): List = - episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted() - -internal fun episodesForSeason(episodes: List, season: Int?): List = - if (season == null) emptyList() - else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator) - -internal fun defaultSeason(episodes: List): Int? = - episodes.firstOrNull { - it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L) - }?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt index 51b8bd3..5cd7f27 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt @@ -3,18 +3,15 @@ package com.ponzischeme89.memby.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,8 +22,6 @@ 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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -37,7 +32,6 @@ 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.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush @@ -45,14 +39,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource 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.Text -import coil.compose.AsyncImage import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.MaintenanceMonitor import com.ponzischeme89.memby.data.ServiceAlert @@ -62,8 +57,13 @@ private val AlertAccent = Color(0xFF52B54B) private val AlertTitle = Color(0xFFF2F5F7) private val AlertBody = Color(0xFFC3CBD2) -/** Height of the bar itself, before the rule and fade that blend it into the screen. */ -private val BannerHeight = 104.dp +/** + * Height of the bar itself, before the rule and fade that blend it into the screen. + * + * Kept to a strip rather than a panel because this now appears over playback as well as + * over the launcher: whatever it says, it is covering somebody's film while it says it. + */ +private val BannerHeight = 48.dp /** * Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so @@ -103,12 +103,14 @@ fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) { AnimatedVisibility( visible = alert != null, - // In from above the frame, out the same way. Slower arriving than leaving: - // showing up should be noticed, going away should not. - enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } + - fadeIn(tween(260)), - exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } + - fadeOut(tween(220)), + // In from above the frame, out the same way, both unhurried: over a film the + // arrival is the intrusion, so it eases in rather than snapping down. Still + // slower arriving than leaving — showing up should be noticed, going away + // should not. + enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } + + fadeIn(tween(520)), + exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } + + fadeOut(tween(300)), modifier = modifier.zIndex(8f), ) { lastAlert?.let { AlertBanner(it) } @@ -141,53 +143,52 @@ internal fun AlertBanner( .fillMaxWidth() .height(BannerHeight) .background( - // Darkest at the left where the text sits, easing off to the right so - // the artwork behind the banner still shows through. + // Near-black, and darkest at the left where the text sits. Over a + // film almost anything can be behind this, so the bar supplies its + // own contrast rather than relying on the picture underneath. Brush.horizontalGradient( - 0f to Color(0xFF0E1418), - 0.55f to Color(0xF20E1418), - 1f to Color(0xD9121A20), + 0f to Color(0xFF04070A), + 0.55f to Color(0xF504070A), + 1f to Color(0xE0070B0F), ), ) .padding(horizontal = SafeAreaHorizontal), verticalAlignment = Alignment.CenterVertically, ) { - AlertPoster(posterUrl = alert.posterUrl) - Spacer(Modifier.width(20.dp)) - Column(Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - LivePip() - Text( - "JUST AIRED", - color = AlertAccent, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.6.sp, - ) - } - Spacer(Modifier.height(3.dp)) - // Keep the alert readable at TV distance without letting it overpower - // the screen content beneath it. - Text( - alert.title, - color = AlertTitle, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - alert.message, - color = AlertBody, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(Modifier.width(24.dp)) + AlertMark() + Spacer(Modifier.width(14.dp)) + // The same short green rule used by the ten-minute reminder. It makes the + // alert feel like part of the player instead of a separate notification UI. + Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent)) + Spacer(Modifier.width(14.dp)) + Text( + alertLabel(alert.label), + color = AlertAccent, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(12.dp)) + Text( + alert.title, + color = AlertTitle, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(10.dp)) + Text( + alert.message, + color = AlertBody, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(16.dp)) CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft) } @@ -196,7 +197,7 @@ internal fun AlertBanner( Box( Modifier .fillMaxWidth() - .height(2.dp) + .height(1.dp) .background( Brush.horizontalGradient( listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent), @@ -206,14 +207,25 @@ internal fun AlertBanner( Box( Modifier .fillMaxWidth() - .height(22.dp) + .height(10.dp) .background( - Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)), + Brush.verticalGradient(listOf(Color(0x66000000), Color.Transparent)), ), ) } } +/** + * The eyebrow above the title. The server picks the wording so it can announce something + * this build has no name for; a bare alert — or one from a server older than the field — + * keeps the original episode wording, and an over-long label is cut rather than allowed + * to push the countdown off the bar. + */ +internal fun alertLabel(label: String): String = + label.trim().ifEmpty { "JUST AIRED" }.take(MaxAlertLabelChars).uppercase() + +private const val MaxAlertLabelChars = 24 + /** * A ring that empties as the banner's time runs out, with the seconds left inside it. * @@ -228,9 +240,9 @@ internal fun AlertBanner( */ @Composable private fun CountdownRing(fraction: () -> Float, secondsLeft: State) { - Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) { + Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) { Canvas(Modifier.fillMaxSize()) { - val stroke = 3.dp.toPx() + val stroke = 2.dp.toPx() val inset = stroke / 2f val arcSize = Size(size.width - stroke, size.height - stroke) drawArc( @@ -257,60 +269,30 @@ private fun CountdownRing(fraction: () -> Float, secondsLeft: State) { Text( secondsLeft.value.coerceAtLeast(0).toString(), color = AlertBody, - fontSize = 16.sp, + fontSize = 9.sp, fontWeight = FontWeight.Medium, ) } } -/** Poster when the server named one; a quiet accent tile when it did not. */ +/** + * The Emby mark, which is what every one of these banners is really speaking for — + * a new film, a finished refresh, a server that stopped answering. + * + * It replaced the item poster deliberately: artwork made each alert look like a different + * feature, and half of them (a library refresh, an outage) have no artwork to show. One + * constant mark says "this is your server talking" in the width of a thumbnail. + */ @Composable -private fun AlertPoster(posterUrl: String?) { - val shape = RoundedCornerShape(6.dp) - Box( +private fun AlertMark() { + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = null, + contentScale = ContentScale.Fit, modifier = Modifier - .width(50.dp) - .height(74.dp) - .clip(shape) - .background(Color(0xFF18222B)), - contentAlignment = Alignment.Center, - ) { - if (posterUrl != null) { - AsyncImage( - model = posterUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - Modifier - .size(20.dp) - .clip(CircleShape) - .background(AlertAccent.copy(alpha = 0.30f)), - ) - } - } -} - -/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */ -@Composable -private fun LivePip() { - val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip") - .animateFloat( - initialValue = 0.35f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - tween(1_400, easing = LinearEasing), - RepeatMode.Reverse, - ), - label = "alert-pip-alpha", - ) - // Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse - // repaints eight dp of circle rather than recomposing the row that holds it. - Canvas(Modifier.size(8.dp)) { - drawCircle(color = AlertAccent.copy(alpha = alpha.value)) - } + .width(30.dp) + .height(26.dp), + ) } // Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's @@ -327,7 +309,38 @@ private fun ServiceAlertBannerPreview() { id = "sonarr:7:42:aired", title = "Northbound", message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.", - posterUrl = null, + ), + ) + } +} + +/** The other news the bar carries: Radarr finished importing a film. */ +@TvPreview +@Composable +private fun ServiceAlertBannerMovieAddedPreview() { + PreviewSurface(alignment = Alignment.TopCenter) { + AlertBanner( + ServiceAlert( + id = "radarr:412:file:9001", + title = "Mr. Smith Goes to Washington (1939)", + message = "Mr. Smith Goes to Washington will be available in Emby shortly.", + label = "NEW MOVIE ADDED", + ), + ) + } +} + +/** News about the service rather than the catalogue — the kind seen during a film. */ +@TvPreview +@Composable +private fun ServiceAlertBannerServerDownPreview() { + PreviewSurface(alignment = Alignment.TopCenter) { + AlertBanner( + ServiceAlert( + id = "emby:down:1785012345", + title = "Emby has stopped communicating", + message = "Playback may stop until it is back. Memby will say when it returns.", + label = "SERVER NOT RESPONDING", ), ) } @@ -344,7 +357,6 @@ private fun ServiceAlertBannerLongTitlePreview() { title = "A Very Long Programme Title That Will Not Fit On One Line At All", message = "S11E03 — The One Where Absolutely Everything Happens At Once " + "aired at 10:30 PM and is downloading now.", - posterUrl = null, ), ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt new file mode 100644 index 0000000..d2599a7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt @@ -0,0 +1,60 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.get +import androidx.core.graphics.drawable.toBitmap +import coil.imageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult + +/** + * Whether a title's logo artwork should be replaced by its plain-text name. + * + * Transparent Emby logos are commonly black. They disappear over a dark backdrop, so a + * small decoded copy is inspected and the text title retained when the visible pixels are + * overwhelmingly dark. Until the image has been inspected, text is the safe default — a + * title that arrives a frame late is better than a title that never appears. + * + * Shared by the screensaver and the detail hero: both draw a title over a near-black scrim, + * and a logo that is invisible on one is invisible on the other. + */ +@Composable +internal fun useTextTitleForLogo(logoUrl: String?): Boolean { + if (logoUrl == null) return true + val context = LocalContext.current + val isDark by produceState(initialValue = true, logoUrl) { + value = runCatching { + val result = context.imageLoader.execute( + ImageRequest.Builder(context) + .data(logoUrl) + .allowHardware(false) + .size(64, 64) + .build(), + ) as? SuccessResult ?: return@runCatching true + isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64)) + }.getOrDefault(true) + } + return isDark +} + +private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean { + var opaquePixels = 0 + var darkPixels = 0 + for (y in 0 until bitmap.height step 2) { + for (x in 0 until bitmap.width step 2) { + val pixel = bitmap[x, y] + if (android.graphics.Color.alpha(pixel) < 48) continue + opaquePixels++ + val luminance = ( + android.graphics.Color.red(pixel) * 0.2126f + + android.graphics.Color.green(pixel) * 0.7152f + + android.graphics.Color.blue(pixel) * 0.0722f + ) + if (luminance < 58f) darkPixels++ + } + } + return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index ac22c27..d730f5a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby.ui +import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -30,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,20 +42,26 @@ 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.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.update.InstallPermissionRequiredException import com.ponzischeme89.memby.update.UpdateChecker import kotlinx.coroutines.launch @@ -63,7 +71,8 @@ private val UpdateBody = Color(0xFFAEB7BF) private val UpdateFaint = Color(0xFFA2ADB5) /** - * The update prompt, shown over the home screen. + * The app-level update gate. AppRoot composes this instead of login, profiles, or Home, + * so no focused media card or playback action exists behind its buttons. * * A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and * there is one button. The operator has decided this build may no longer be used, so @@ -81,13 +90,53 @@ fun UpdateScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() val checker = remember { UpdateChecker(context) } var installing by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } + var waitingForInstallPermission by remember { mutableStateOf(false) } + + fun beginInstall() { + if (installing) return + installing = true + message = null + scope.launch { + val result = checker.downloadAndInstall( + apkUrl = update.downloadUrl, + token = "", + expectedVersion = update.version, + expectedSHA256 = update.sha256, + expectedSizeBytes = update.sizeBytes, + ) + installing = false + waitingForInstallPermission = + result.exceptionOrNull() is InstallPermissionRequiredException + message = result.exceptionOrNull()?.message ?: "Opening the installer…" + } + } + + // Android's permission screen pauses Memby. Once the viewer grants permission and + // returns, continue automatically instead of making them discover they must press + // Update now for a second time. + DisposableEffect(lifecycleOwner, waitingForInstallPermission, update.version) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && + waitingForInstallPermission && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + context.packageManager.canRequestPackageInstalls()) + ) { + waitingForInstallPermission = false + beginInstall() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val primaryFocus = remember { FocusRequester() } + val laterFocus = remember { FocusRequester() } LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } } // Swallow Back entirely while an update is required. For an optional prompt, Back is @@ -120,13 +169,7 @@ fun UpdateScreen( modifier = modifier .fillMaxSize() // Opaque, not a scrim: a required update is not a dialog over usable content. - .background(Color(0xFF0B0E11)) - // Consumes clicks so nothing behind can be reached. - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), + .background(Color(0xFF0B0E11)), ) { Canvas(Modifier.fillMaxSize()) { val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f) @@ -209,7 +252,7 @@ fun UpdateScreen( // easy to back out of by accident. "Choose Update now — Memby downloads the new version, then your TV asks you " + "to confirm the install. If it asks permission to install apps, allow it and " + - "the update continues.", + "the update continues. Your profiles and sign-in stay on this TV.", color = UpdateFaint, fontSize = 14.sp, textAlign = TextAlign.Center, @@ -222,21 +265,23 @@ fun UpdateScreen( label = if (installing) "Downloading…" else "Update now", primary = true, enabled = !installing, - onClick = { - if (installing) return@UpdateButton - installing = true - message = null - scope.launch { - val result = checker.downloadAndInstall(update.downloadUrl, token = "") - installing = false - message = result.exceptionOrNull()?.message - ?: "Opening the installer…" - } - }, - modifier = Modifier.focusRequester(primaryFocus), + onClick = ::beginInstall, + modifier = Modifier + .focusRequester(primaryFocus) + .focusProperties { + if (!update.isMandatory) right = laterFocus + }, ) if (!update.isMandatory) { - UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss) + UpdateButton( + label = "Not now", + primary = false, + enabled = true, + onClick = onDismiss, + modifier = Modifier + .focusRequester(laterFocus) + .focusProperties { left = primaryFocus }, + ) } } @@ -252,6 +297,13 @@ fun UpdateScreen( color = UpdateFaint.copy(alpha = 0.75f), fontSize = 13.sp, ) + } else { + Spacer(Modifier.height(18.dp)) + Text( + "Press Back or choose Not now to dismiss this update.", + color = UpdateFaint.copy(alpha = 0.82f), + fontSize = 13.sp, + ) } } } @@ -290,6 +342,25 @@ private fun UpdateButton( shape = RoundedCornerShape(10.dp), ) .onFocusChanged { focused = it.isFocused } + // Some Android TV launchers/remotes do not turn a Foundation click target's + // centre key into a click consistently. Consume the activation keys here and + // invoke once on key-up, while retaining clickable for accessibility/pointer + // input. + .onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + val activationKey = + native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_ENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER + if (!activationKey) { + false + } else { + if (enabled && native.action == android.view.KeyEvent.ACTION_UP) { + onClick() + } + true + } + } .focusable(interactionSource = remember { MutableInteractionSource() }) .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 30.dp, vertical = 13.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt new file mode 100644 index 0000000..d7e99e8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -0,0 +1,25 @@ +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. + */ +internal fun userSwitcherInitialIndex( + profileIds: List, + activeProfileId: String?, +): Int = profileIds.indexOf(activeProfileId).takeIf { it >= 0 } ?: 0 + +internal fun userSwitcherNextIndex( + currentIndex: Int, + profileCount: Int, + direction: UserSwitcherDirection, +): Int { + val manageIndex = profileCount.coerceAtLeast(0) + val current = currentIndex.coerceIn(0, manageIndex) + return when (direction) { + UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0) + UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt new file mode 100644 index 0000000..2ea9713 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt @@ -0,0 +1,59 @@ +package com.ponzischeme89.memby.ui + +import kotlin.random.Random + +internal enum class WelcomeQuoteStyle( + val value: String, + val label: String, +) { + NEUTRAL("neutral", "Neutral"), + POSITIVE("positive", "Positive"), + HOMICIDAL("homicidal", "Homicidal"), + ; + + companion object { + fun from(value: String?): WelcomeQuoteStyle = + entries.firstOrNull { it.value.equals(value, ignoreCase = true) } ?: NEUTRAL + } +} + +private val WelcomeQuotes = mapOf( + WelcomeQuoteStyle.NEUTRAL to listOf( + "The sofa has been expecting you.", + "Your watchlist remains impressively optimistic.", + "Everything is ready. Decision-making is now your problem.", + "Welcome back. The pixels have been briefed.", + "No judgement. Even if you pick that again.", + ), + WelcomeQuoteStyle.POSITIVE to listOf( + "Excellent choice showing up. The rest should be easy.", + "You bring the snacks; Memby will bring the good bits.", + "Tonight has strong main-character energy.", + "Your next favourite thing might be one click away.", + "Settle in. You’ve earned the good seat.", + ), + WelcomeQuoteStyle.HOMICIDAL to listOf( + "Welcome back. I kept your spot. Nobody argued twice.", + "Pick something cheerful. I’ve already hidden the evidence.", + "The remote knows what it did.", + "Your watchlist is safe. The witnesses, less so.", + "Relax. Everything is under control, allegedly.", + ), +) + +internal fun randomWelcomeQuote( + styleValue: String?, + random: Random = Random.Default, +): String { + val quotes = WelcomeQuotes.getValue(WelcomeQuoteStyle.from(styleValue)) + return quotes[random.nextInt(quotes.size)] +} + +internal fun loginWelcomeMessage( + username: String, + styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value, + random: Random = Random.Default, +): String { + val name = username.trim().ifBlank { "there" } + return "Welcome to Memby, $name. ${randomWelcomeQuote(styleValue, random)}" +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt index 9c078b9..ba82c91 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.ui.detail import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.theme.ValueSeparator import java.util.Locale /** @@ -15,6 +16,15 @@ import java.util.Locale /** Emby stores durations and positions as 100-ns ticks. */ private const val TICKS_PER_MINUTE = 600_000_000L +/** + * The video width at which a file is called 4K, by the spec row and the card badge alike. + * + * UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the + * threshold sits just under it. The badge and the `(4K)` suffix used to disagree — 3800 and + * 3400 — and a 3600-wide file was 4K on one screen and not the other. + */ +const val UHD_MIN_WIDTH = 3_800 + /** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */ fun formatRuntime(minutes: Int): String { val hours = minutes / 60 @@ -52,27 +62,100 @@ fun remainingLabel(item: BaseItem): String? { return "${formatPosition(left)} left" } -/** The headline row under a movie title: year, runtime, certificate, score, genres. */ -fun movieFacts(item: BaseItem): List = buildList { +/** + * The quiet line directly under the title: year, length, certificate. Deliberately short — + * the score sits beside the title and the genres are a credit row, so this stays at three + * items and never has to compete for the width. + * + * [seasonCount] replaces the runtime for a series; pass 0 for anything else. + */ +fun heroFacts(item: BaseItem, seasonCount: Int = 0): List = buildList { item.productionYear?.let { add(it.toString()) } - item.runtimeMinutes?.let { add(formatRuntime(it)) } + if (seasonCount > 0) { + add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}") + } else { + item.runtimeMinutes?.let { add(formatRuntime(it)) } + } item.officialRating?.takeIf(String::isNotBlank)?.let(::add) - item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") } - item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add) } -/** The same row for a series, where seasons replace runtime. */ -fun seriesFacts(item: BaseItem, seasonCount: Int): List = buildList { - item.productionYear?.let { add(it.toString()) } - if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}") - item.officialRating?.takeIf(String::isNotBlank)?.let(::add) - item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") } - item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add) -} +/** "8.9" for the score beside the title, or null when Emby has no community rating. */ +fun ratingLabel(item: BaseItem): String? = + item.communityRating?.let { String.format(Locale.US, "%.1f", it) } /** A label/value pair for the quiet technical area. */ data class TechnicalSpec(val label: String, val value: String) +/** + * The label/value credits block: who is in it, who made it, what it is. Kept separate from + * [technicalSpecs] because these answer "do I want this?" and those answer "will it play?". + */ +fun creditRows(item: BaseItem): List = buildList { + peopleNamed(item, "Actor", limit = 4)?.let { add(TechnicalSpec("Starring", it)) } + val directors = peopleNamed(item, "Director", limit = 2) + val creators = peopleNamed(item, "Writer", limit = 2) + when { + directors != null -> add(TechnicalSpec("Directed by", directors)) + creators != null -> add(TechnicalSpec("Written by", creators)) + else -> Unit + } + item.genres.take(3).joinToString(", ").takeIf(String::isNotBlank) + ?.let { add(TechnicalSpec("Genre", it)) } + item.studios.map { it.name }.filter(String::isNotBlank).take(2) + .takeIf(List::isNotEmpty) + ?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) } +} + +private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? = + item.people + .filter { it.type.equals(type, ignoreCase = true) } + .map { it.name } + .filter(String::isNotBlank) + .distinct() + .take(limit) + .takeIf(List::isNotEmpty) + ?.joinToString(", ") + +/** + * The sections a detail page can show, in the order the strip lists them. + * + * Overview is always present and always first: it is the only one guaranteed to have + * something in it, so it is what the page can safely open on. + */ +enum class DetailTab(val key: String, val label: String) { + OVERVIEW("overview", "Overview"), + MORE_LIKE_THIS("more-like-this", "More Like This"), + EPISODES("episodes", "Episodes"), + CAST_DETAILS("cast-details", "Cast & Details"), +} + +/** + * The strip for one item, decided by *what the item is* — never by what has arrived from + * the network so far. + * + * This used to offer only the sections that already had content, on the reasoning that an + * empty tab is worse than a missing one. On a real TV it was worse than either: a movie + * opens with its list-row metadata, so Cast and Details appeared a second later and shoved + * the strip sideways under the viewer's thumb, and a series that failed to load its + * episodes lost a tab the household knows is there. A strip that depends only on + * [isSeries] is decided on the first frame and never moves again; a section with nothing + * in it yet says so in its own pane, where saying so costs nobody a keypress. + */ +fun detailTabs(isSeries: Boolean): List = buildList { + add(DetailTab.OVERVIEW) + if (isSeries) add(DetailTab.EPISODES) + add(DetailTab.MORE_LIKE_THIS) + add(DetailTab.CAST_DETAILS) +} + +/** + * Resolves a remembered tab key against what is on offer. The strip no longer changes + * under a page, so this only has to catch a key remembered from an item of the other kind + * — a series' Episodes tab carried over to a movie. + */ +fun detailTab(key: String, available: List): DetailTab = + available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW + /** * Resolution, codecs and studio — the things a viewer checks before settling in, kept out * of the headline because none of them decide what to watch. @@ -92,7 +175,7 @@ fun technicalSpecs(item: BaseItem): List { stream.codec?.uppercase(Locale.US), dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title), ).takeIf(List::isNotEmpty)?.let { - add(TechnicalSpec("Codec", it.joinToString(" · "))) + add(TechnicalSpec("Codec", it.joinToString(ValueSeparator))) } } audio?.let { stream -> @@ -101,7 +184,7 @@ fun technicalSpecs(item: BaseItem): List { stream.channels?.let(::channelLabel), stream.language?.takeIf(String::isNotBlank), ).takeIf(List::isNotEmpty)?.let { - add(TechnicalSpec("Audio", it.joinToString(" · "))) + add(TechnicalSpec("Audio", it.joinToString(ValueSeparator))) } } if (subtitles > 0) { @@ -114,14 +197,19 @@ fun technicalSpecs(item: BaseItem): List { } private fun resolutionSuffix(width: Int): String = when { - width >= 3_400 -> " (4K)" + width >= UHD_MIN_WIDTH -> " (4K)" width >= 2_500 -> " (1440p)" width >= 1_800 -> " (1080p)" width >= 1_200 -> " (720p)" else -> "" } -private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? { +/** + * "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an + * ordinary SDR file. Shared with the card badges so the two never name the same file + * differently — HDR10+ used to collapse to a plain "HDR" badge. + */ +internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? { val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US) return when { "dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision" @@ -139,6 +227,26 @@ private fun channelLabel(channels: Int): String = when (channels) { else -> "${channels}ch" } +/** The canonical first film in an Emby collection, when the collection has siblings. */ +data class FranchiseStart(val name: String, val firstMovie: BaseItem) + +fun franchiseStart(item: BaseItem, related: List): FranchiseStart? { + if (!item.isMovie) return null + val collection = item.collectionName?.trim()?.takeIf(String::isNotEmpty) ?: return null + val movies = (listOf(item) + related) + .asSequence() + .filter { candidate -> + candidate.isMovie && candidate.collectionName?.trim().equals(collection, ignoreCase = true) + } + .distinctBy(BaseItem::id) + .toList() + if (movies.size < 2) return null + val first = movies.minWithOrNull( + compareBy({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }), + ) ?: return null + return FranchiseStart(collection, first) +} + // --------------------------------------------------------------------------- // Series structure // --------------------------------------------------------------------------- @@ -196,7 +304,7 @@ fun episodeLabel(episode: BaseItem): String? { /** "S2 E4 · The Crossing", falling back to whichever half is known. */ fun episodeHeadline(episode: BaseItem): String = listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank)) - .joinToString(" · ") + .joinToString(ValueSeparator) /** * The primary button. Series pass their next episode so the button can name it; a movie diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailPosition.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailPosition.kt new file mode 100644 index 0000000..924a0b8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailPosition.kt @@ -0,0 +1,69 @@ +package com.ponzischeme89.memby.ui.detail + +/** + * Where a viewer was on a detail page, so that leaving and coming back does not undo it. + * + * A detail page is an overlay: closing it removes the composable entirely, which takes + * `rememberSaveable` with it. Without somewhere outside the composition to keep this, + * pressing Back on episode 9 of season 3 and opening the show again lands on Overview, + * season 1, with focus on Play — every single time. + * + * Deliberately process-scoped rather than persisted. It is a convenience within a sitting; + * a TV switched on the next morning should open a show where the *show* is up to, which is + * what [defaultSeason] already decides. + */ +data class DetailPosition( + val tabKey: String = DetailTab.OVERVIEW.key, + /** Null means "not chosen yet" — [defaultSeason] still gets to pick. */ + val season: Int? = null, + val zone: DetailZone = DetailZone.PLAY, + val episodeIndex: Int = 0, + val relatedIndex: Int = 0, +) + +/** + * Which band of the page held focus. Restoring the *tab* without the band is worse than + * restoring neither: the viewer left with focus on an episode and comes back to a page + * that looks identical but answers Down differently. + */ +enum class DetailZone { PLAY, TABS, CONTENT, RELATED } + +/** + * A small, capped, most-recently-used store of positions keyed by item id. + * + * Capped because a long browse would otherwise accumulate one entry per poster the viewer + * pressed OK on, and none of them matter once they have scrolled out of memory. + */ +class DetailPositionStore(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) { + private val positions = LinkedHashMap(maxEntries, 0.75f, true) + + @Synchronized + fun get(itemId: String): DetailPosition = positions[itemId] ?: DetailPosition() + + @Synchronized + fun update(itemId: String, transform: (DetailPosition) -> DetailPosition) { + if (itemId.isBlank()) return + positions[itemId] = transform(positions[itemId] ?: DetailPosition()) + while (positions.size > maxEntries) { + val oldest = positions.keys.firstOrNull() ?: break + positions.remove(oldest) + } + } + + @Synchronized + fun clear() = positions.clear() + + @Synchronized + fun size(): Int = positions.size + + companion object { + const val DEFAULT_MAX_ENTRIES = 16 + } +} + +/** + * The one store the detail pages share. A plain global rather than a `ServiceLocator` + * entry: it holds nothing a preview, a screenshot test or a signed-out user could misuse, + * and every reader of it is a composable that already runs without injection. + */ +val detailPositions = DetailPositionStore() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityLogo.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityLogo.kt new file mode 100644 index 0000000..3bca818 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityLogo.kt @@ -0,0 +1,47 @@ +package com.ponzischeme89.memby.ui.player + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.core.graphics.drawable.toBitmap + +/** + * Applies a white treatment only to artwork whose visible pixels are overwhelmingly dark. + * Transparent padding is ignored, so sparse black wordmarks are detected reliably without + * flattening colourful or deliberately two-tone artwork. + */ +internal fun makeLogoVisibleOnDarkBackground(imageView: ImageView, drawable: Drawable): Boolean { + imageView.setImageDrawable(drawable) + imageView.clearColorFilter() + val tintWhite = isPredominantlyDarkLogo(drawable) + if (tintWhite) imageView.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) + return tintWhite +} + +internal fun isPredominantlyDarkLogo(drawable: Drawable): Boolean { + val bitmap = runCatching { + drawable.toBitmap(width = 96, height = 64, config = Bitmap.Config.ARGB_8888) + }.getOrNull() ?: return false + val xStep = (bitmap.width / 48).coerceAtLeast(1) + val yStep = (bitmap.height / 32).coerceAtLeast(1) + var visible = 0 + var dark = 0 + var bright = 0 + for (y in 0 until bitmap.height step yStep) { + for (x in 0 until bitmap.width step xStep) { + val pixel = bitmap.getPixel(x, y) + if (Color.alpha(pixel) < 32) continue + visible++ + val luminance = ( + Color.red(pixel) * 0.2126f + + Color.green(pixel) * 0.7152f + + Color.blue(pixel) * 0.0722f + ) / 255f + if (luminance < 0.32f) dark++ + if (luminance > 0.72f) bright++ + } + } + return visible > 0 && dark * 100 >= visible * 68 && bright * 100 < visible * 12 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt index 9a522ed..f34a711 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -10,6 +10,7 @@ internal data class PlaybackFailure( val title: String, val detail: String, val canAutoRetry: Boolean, + val requiresTranscode: Boolean = false, ) internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure = @@ -45,17 +46,26 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure = PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, -> PlaybackFailure( title = "Video format not supported", - detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.", - canAutoRetry = false, + detail = "This TV couldn’t decode the original format. Memby will request a compatible stream.", + canAutoRetry = true, + requiresTranscode = true, ) PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED, -> PlaybackFailure( title = "Video file couldn’t be read", - detail = "The stream format is damaged or unsupported by this TV.", + detail = "This TV couldn’t read the original stream. Memby will request a compatible format.", + canAutoRetry = true, + requiresTranscode = true, + ) + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, + -> PlaybackFailure( + title = "Video file couldn’t be read", + detail = "The media server returned a malformed stream.", canAutoRetry = false, ) 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 cd92741..e8a7d21 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 @@ -14,16 +14,23 @@ import android.text.format.DateFormat import android.util.Log import android.view.Gravity import android.view.KeyEvent +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.annotation.OptIn +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -56,7 +63,11 @@ import com.ponzischeme89.memby.data.PlaybackSession import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.GatewayPrerollEntry import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule +import com.ponzischeme89.memby.data.model.GatewaySeasonFinale import com.ponzischeme89.memby.ui.MainActivity +import com.ponzischeme89.memby.ui.ServiceAlertBanner +import com.ponzischeme89.memby.ui.randomWelcomeQuote +import com.ponzischeme89.memby.ui.theme.MembyTheme import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull @@ -80,7 +91,9 @@ class PlayerActivity : ComponentActivity() { private var playerView: PlayerView? = null private var progressJob: Job? = null private var playbackStarted = false + private var initialResumePositionMs = 0L private var stopReported = false + private var autoFollowEvaluated = false private var itemId: String? = null private var mediaSourceId = "" private var playSessionId = "" @@ -93,6 +106,9 @@ class PlayerActivity : ComponentActivity() { private var loadingView: View? = null private var loadingTitleView: TextView? = null private var loadingHintView: TextView? = null + private val playbackLoadingQuote: String by lazy { + randomWelcomeQuote(ServiceLocator.settings.current?.welcomeQuoteStyle) + } private var loadingAnimator: ObjectAnimator? = null private var errorView: View? = null private var errorTitleView: TextView? = null @@ -105,6 +121,16 @@ class PlayerActivity : ComponentActivity() { private var requestStartedAtMs = 0L private var logoUrl: String? = null private var playbackTitle = "" + private var pauseOverview = "" + private var prerollEpisodeCode = "" + private var prerollRuntimeMs = 0L + private var prerollDurationMs = DEFAULT_PREROLL_DURATION_MS + private var pausePosterUrl: String? = null + private var pauseOverlay: View? = null + private var nowPlayingGroup: View? = null + private var playbackIdentityView: View? = null + private var playbackIdentityHideJob: Job? = null + private var playbackIdentityShown = false private var bufferingStartedAtMs: Long? = null private var totalBufferingMs = 0L private var bufferingCount = 0 @@ -122,16 +148,42 @@ class PlayerActivity : ComponentActivity() { private var prerollActive = true private var prerollHandOffStarted = false private var prerollMinimumElapsed = false + /** + * Whether the service-alert bar is held back. Starts true: the preroll and the + * loading overlay both come before the first frame, and an alert shown over either + * would be marked seen by a viewer who never saw it. + */ + private val alertsSuppressed = mutableStateOf(true) + private var fullscreenPlayerParent: ViewGroup? = null + private var fullscreenPlayerIndex = -1 + private var fullscreenPlayerLayoutParams: ViewGroup.LayoutParams? = null // "Next up" state. [nextEpisode] is prefetched as soon as playback settles so the // banner can appear — and the next episode start — without waiting on the network. private var nextEpisode: NextEpisode? = null + private var returningHomeAfterCompletion = false private var nextUpJob: Job? = null private var nextUpBanner: View? = null private var nextUpCountdown: TextView? = null private var nextUpDismissed = false private var advancing = 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 + private var timeRemainingLabel: TextView? = null + private var timeRemainingValue: TextView? = null + private var timeRemainingHideJob: Job? = null + private var timeRemainingShown = false + private var playbackStartCueJob: Job? = null + private var playbackStartCueShown = false + private var seasonFinaleCue: View? = null + private var seasonFinaleValue: TextView? = null + private var seasonFinaleInfo: GatewaySeasonFinale? = null + private var seasonFinaleJob: Job? = null + private var seasonFinaleHideJob: Job? = null + private var seasonFinaleShown = false + @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -147,6 +199,13 @@ class PlayerActivity : ComponentActivity() { playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty() playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD) ?: "DirectPlay" val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) + initialResumePositionMs = resumePositionMs.coerceAtLeast(0L) + val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) + prerollDurationMs = intent.getLongExtra( + EXTRA_PREROLL_DURATION_MS, + DEFAULT_PREROLL_DURATION_MS, + ).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS) + val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled) val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES)) availableSubtitles = subtitles Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}") @@ -172,9 +231,14 @@ class PlayerActivity : ComponentActivity() { showTrackMenu() } applyPictureMode() + mountServiceAlerts() loadingView = findViewById(R.id.playback_loading) loadingTitleView = findViewById(R.id.playback_loading_title) - loadingHintView = findViewById(R.id.playback_loading_hint) + loadingHintView = findViewById(R.id.playback_loading_hint).also { + // Set this as soon as the layout is mounted so the XML fallback never flashes + // before the first buffering callback. + it.text = playbackLoadingQuote + } loadingAnimator = ObjectAnimator.ofFloat( findViewById(R.id.playback_loading_logo), View.ROTATION, @@ -193,12 +257,20 @@ class PlayerActivity : ComponentActivity() { streamStatusView = view.findViewById(R.id.player_stream_status) logoUrl = intent.getStringExtra(EXTRA_LOGO_URL) playbackTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() + pauseOverview = intent.getStringExtra(EXTRA_OVERVIEW).orEmpty() + prerollEpisodeCode = intent.getStringExtra(EXTRA_EPISODE_CODE).orEmpty() + prerollRuntimeMs = intent.getLongExtra(EXTRA_RUNTIME_MS, 0L).coerceAtLeast(0L) + pausePosterUrl = intent.getStringExtra(EXTRA_POSTER_URL) bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) + bindPauseOverlay(view) + setUpPlaybackIdentity(title = playbackTitle) setUpSubtitleOverlay() setUpCastOverlay() setUpNextUpBanner() + setUpTimeRemainingCue() + setUpSeasonFinaleCue() setUpPlaybackError() - startPreroll() + if (showPreroll) startPreroll() else startWithoutPreroll() loadCast() val selector = DefaultTrackSelector(this) @@ -230,13 +302,13 @@ class PlayerActivity : ComponentActivity() { Player.STATE_BUFFERING -> if (!prerollActive) showPlaybackLoading() Player.STATE_READY -> { hidePlaybackError() + if (prerollActive) bindPrerollNow(playback.duration) if (!prerollActive && renderedFirstFrame) { hidePlaybackLoading() } + updatePauseOverlay(playback) } - Player.STATE_ENDED -> nextEpisode - ?.takeIf { !nextUpDismissed } - ?.let(::playNext) + Player.STATE_ENDED -> handlePlaybackEnded() Player.STATE_IDLE -> Unit } } @@ -250,6 +322,7 @@ class PlayerActivity : ComponentActivity() { ) } if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel() + updatePauseOverlay(playback) } override fun onEvents(player: Player, events: Player.Events) { @@ -276,6 +349,12 @@ class PlayerActivity : ComponentActivity() { override fun onRenderedFirstFrame() { renderedFirstFrame = true hidePlaybackLoading() + if (!playbackStarted) { + startPlaybackSession(playback) + } + if (prerollActive) { + startPrerollCountdown() + } Log.i( PLAYBACK_LOG_TAG, "First video frame rendered in " + @@ -284,9 +363,9 @@ class PlayerActivity : ComponentActivity() { } }) playback.setMediaItem(mediaItem(url, subtitles), resumePositionMs) - // Buffer the real programme while the cheap pre-roll overlay is visible. - // No second player, decoder or media request is involved. - playback.playWhenReady = false + // Fresh playback waits in the pre-roll frame. A resumed item is already + // in its full-screen parent and should begin as soon as it is ready. + playback.playWhenReady = !showPreroll playback.prepare() } @@ -311,27 +390,113 @@ class PlayerActivity : ComponentActivity() { useController = false hideController() } + enterPrerollVideoFrame() + bindPrerollNow() bindPrerollSchedule(GatewayPrerollSchedule(), loading = true) prerollScheduleJob = lifecycleScope.launch { val schedule = ServiceLocator.repository.prerollSchedule() if (prerollActive) bindPrerollSchedule(schedule, loading = false) } + findViewById(R.id.player_preroll_countdown).setCountdown( + seconds = ceil(prerollDurationMs / 1_000.0).toInt(), + progress = 1f, + description = resources.getQuantityString( + R.plurals.player_preroll_countdown, + ceil(prerollDurationMs / 1_000.0).toInt(), + ceil(prerollDurationMs / 1_000.0).toInt(), + ), + ) + } + + /** Resume is a continuation, not a new programme start, so it bypasses the pre-roll. */ + private fun startWithoutPreroll() { + prerollView = findViewById(R.id.player_preroll).also { + it.visibility = View.GONE + } + prerollActive = false + prerollHandOffStarted = false + prerollMinimumElapsed = false + playerView?.apply { + useController = true + hideController() + } + showPlaybackLoading() + } + + private fun startPrerollCountdown() { + if (prerollTimerJob != null) return prerollTimerJob = lifecycleScope.launch { - val countdown = findViewById(R.id.player_preroll_countdown) - for (seconds in PREROLL_SECONDS downTo 1) { - countdown.text = resources.getQuantityString( + val countdown = findViewById(R.id.player_preroll_countdown) + val durationMs = prerollDurationMs + var watchedMs = 0L + var lastTickMs = SystemClock.elapsedRealtime() + while (prerollActive && watchedMs < durationMs) { + val remainingMs = durationMs - watchedMs + val seconds = ceil(remainingMs / 1_000.0).toInt().coerceAtLeast(1) + val description = resources.getQuantityString( R.plurals.player_preroll_countdown, seconds, seconds, ) - delay(1_000L) + countdown.setCountdown( + seconds = seconds, + progress = remainingMs.toFloat() / durationMs, + description = description, + ) + delay(PREROLL_TIMER_TICK_MS) + val nowMs = SystemClock.elapsedRealtime() + if ( + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && + player?.isPlaying == true + ) { + watchedMs = (watchedMs + nowMs - lastTickMs).coerceAtMost(durationMs) + } + lastTickMs = nowMs } + if (!prerollActive) return@launch prerollMinimumElapsed = true - countdown.text = getString(R.string.player_preroll_starting) + countdown.setCountdown( + seconds = 0, + progress = 0f, + description = getString(R.string.player_preroll_starting), + ) beginContentWhenReady() } } + private fun enterPrerollVideoFrame() { + val view = playerView ?: return + val currentParent = view.parent as? ViewGroup ?: return + if (fullscreenPlayerParent != null) return + val host = findViewById(R.id.player_preroll_video_host) + fullscreenPlayerParent = currentParent + fullscreenPlayerIndex = currentParent.indexOfChild(view) + fullscreenPlayerLayoutParams = view.layoutParams + currentParent.removeView(view) + host.addView( + view, + 0, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + + private fun restoreFullscreenPlayer() { + val view = playerView ?: return + val parent = fullscreenPlayerParent ?: return + (view.parent as? ViewGroup)?.removeView(view) + parent.addView( + view, + fullscreenPlayerIndex.coerceIn(0, parent.childCount), + fullscreenPlayerLayoutParams, + ) + fullscreenPlayerParent = null + fullscreenPlayerIndex = -1 + fullscreenPlayerLayoutParams = null + } + private fun beginContentWhenReady() { if (prerollHandOffStarted) return if (!prerollCanHandOff( @@ -343,80 +508,157 @@ class PlayerActivity : ComponentActivity() { val playback = player ?: return prerollHandOffStarted = true prerollActive = false + animatePrerollToFullscreen(playback) + } + + private fun animatePrerollToFullscreen(playback: Player) { + val overlay = prerollView ?: return finishPrerollHandOff(playback) + val host = findViewById(R.id.player_preroll_video_host) + if (host.width == 0 || host.height == 0 || overlay.width == 0 || overlay.height == 0) { + finishPrerollHandOff(playback) + return + } + val overlayLocation = IntArray(2).also(overlay::getLocationOnScreen) + val hostLocation = IntArray(2).also(host::getLocationOnScreen) + val targetScale = maxOf( + overlay.width.toFloat() / host.width, + overlay.height.toFloat() / host.height, + ) + val targetTranslationX = + overlayLocation[0] + overlay.width / 2f - (hostLocation[0] + host.width / 2f) + val targetTranslationY = + overlayLocation[1] + overlay.height / 2f - (hostLocation[1] + host.height / 2f) + + findViewById(R.id.player_preroll_schedule_panel).animate() + .alpha(0f) + .setDuration(PREROLL_FADE_MS) + .start() + findViewById(R.id.player_preroll_calendar).animate() + .alpha(0f) + .setDuration(PREROLL_FADE_MS) + .start() + findViewById(R.id.player_preroll_brand).animate() + .alpha(0f) + .setDuration(PREROLL_FADE_MS) + .start() + findViewById(R.id.player_preroll_countdown_container).animate() + .alpha(0f) + .setDuration(PREROLL_FADE_MS) + .start() + host.translationZ = dp(24).toFloat() + host.animate() + .scaleX(targetScale) + .scaleY(targetScale) + .translationX(targetTranslationX) + .translationY(targetTranslationY) + .setDuration(PREROLL_TRANSITION_MS) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { finishPrerollHandOff(playback) } + .start() + } + + private fun finishPrerollHandOff(playback: Player) { + restoreFullscreenPlayer() prerollView?.visibility = View.GONE playerView?.apply { useController = true hideController() } startPlaybackSession(playback) + showPlaybackIdentity() playback.playWhenReady = true if (playback.playbackState != Player.STATE_READY) { showPlaybackLoading() + } else { + updatePlaybackTiming(playback) } } private fun startPlaybackSession(playback: Player) { if (playbackStarted) return playbackStarted = true + if (!prerollActive) showPlaybackIdentity() reportStarted(playback.currentPosition) startProgressReporting() + startPlaybackStartCueWatch() + prefetchSeasonFinale() prefetchNextEpisode() startNextUpWatch() } private fun bindPrerollSchedule(schedule: GatewayPrerollSchedule, loading: Boolean) { - val today = findViewById(R.id.player_preroll_today) - val week = findViewById(R.id.player_preroll_week) - today.removeAllViews() - week.removeAllViews() - - if (schedule.today.isEmpty() && schedule.thisWeek.isEmpty()) { - today.addView( + val calendar = findViewById(R.id.player_preroll_calendar) + calendar.removeAllViews() + val entries = ( + schedule.today.map { "TODAY" to it } + + schedule.thisWeek.map { "THIS WEEK" to it } + ).take(PREROLL_CALENDAR_SIZE) + if (entries.isEmpty()) { + calendar.addView( prerollMessage( if (loading) "Checking what’s coming up…" else "Nothing else is scheduled today or this week.", ), - ) - } else { - schedule.today.forEach { today.addView(prerollEntry(it)) } - schedule.thisWeek.forEach { week.addView(prerollEntry(it)) } - } - findViewById(R.id.player_preroll_today_label).visibility = - if (schedule.today.isNotEmpty() || schedule.thisWeek.isEmpty()) View.VISIBLE else View.GONE - findViewById(R.id.player_preroll_week_label).visibility = - if (schedule.thisWeek.isNotEmpty()) View.VISIBLE else View.GONE - } - - private fun prerollEntry(entry: GatewayPrerollEntry): View = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(11) } - addView( - TextView(this@PlayerActivity).apply { - text = entry.series - setTextColor(Color.WHITE) - textSize = 16f - typeface = Typeface.create("sans-serif", Typeface.BOLD) - maxLines = 1 + GridLayout.LayoutParams().apply { + width = 0 + height = ViewGroup.LayoutParams.WRAP_CONTENT + columnSpec = GridLayout.spec(0, PREROLL_CALENDAR_COLUMNS, 1f) + rowSpec = GridLayout.spec(0) }, ) - val detail = listOfNotNull( + return + } + entries.forEachIndexed { index, (label, entry) -> + val cardLabel = listOf( + label.takeIf { label == "TODAY" }, entry.schedule.takeIf(String::isNotBlank), + ).filterNotNull().joinToString(" · ").uppercase() + calendar.addView( + prerollEntry(entry, cardLabel), + GridLayout.LayoutParams().apply { + width = 0 + height = dp(PREROLL_CALENDAR_CARD_HEIGHT_DP) + columnSpec = GridLayout.spec(index % PREROLL_CALENDAR_COLUMNS, 1f) + rowSpec = GridLayout.spec(index / PREROLL_CALENDAR_COLUMNS) + setMargins(dp(4), dp(3), dp(4), dp(3)) + }, + ) + } + } + + private fun bindPrerollNow(playerDurationMs: Long = 0L) { + findViewById(R.id.player_preroll_now_title).text = + playbackTitle.ifBlank { "Starting playback" } + val runtime = formatPrerollRuntime( + playerDurationMs.takeIf { it != C.TIME_UNSET && it > 0L } ?: prerollRuntimeMs, + ) + findViewById(R.id.player_preroll_now_metadata).text = listOfNotNull( + if (prerollEpisodeCode.isNotBlank()) "Episode" else null, + prerollEpisodeCode.takeIf(String::isNotBlank), + runtime, + ).joinToString(" · ") + findViewById(R.id.player_preroll_now_overview).text = + pauseOverview.ifBlank { "Memby is preparing this episode for playback." } + } + + private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View = + LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply { + findViewById(R.id.player_preroll_card_label).text = label + findViewById(R.id.player_preroll_card_title).text = entry.series + findViewById(R.id.player_preroll_card_detail).text = listOfNotNull( entry.episodeCode.takeIf(String::isNotBlank), entry.episode.takeIf(String::isNotBlank), entry.availability.takeIf(String::isNotBlank), ).joinToString(" · ") - addView( - TextView(this@PlayerActivity).apply { - text = detail - setTextColor(Color.rgb(142, 151, 157)) - textSize = 12f - maxLines = 1 - }, - ) + ServiceLocator.repository.prerollArtworkUrl( + entry.itemId, + entry.imageType, + 640, + )?.let { artwork -> + findViewById(R.id.player_preroll_card_artwork).load(artwork) { + crossfade(true) + } + } } private fun prerollMessage(message: String): TextView = @@ -429,18 +671,23 @@ class PlayerActivity : ComponentActivity() { private fun showPlaybackLoading( title: String = getString(R.string.playback_loading), - hint: String = getString(R.string.playback_loading_hint), + hint: String = playbackLoadingQuote, ) { if (prerollActive) return loadingTitleView?.text = title loadingHintView?.text = hint loadingView?.visibility = View.VISIBLE if (loadingAnimator?.isStarted != true) loadingAnimator?.start() + alertsSuppressed.value = true } private fun hidePlaybackLoading() { loadingView?.visibility = View.GONE loadingAnimator?.cancel() + // Reached only once the first frame is on screen and the preroll is over, so + // this is exactly "the film is playing" — the only state in which a notice + // about the library is worth interrupting anyone for. + alertsSuppressed.value = errorView?.visibility == View.VISIBLE } private fun setUpPlaybackError() { @@ -458,8 +705,10 @@ class PlayerActivity : ComponentActivity() { } private fun handlePlaybackError(error: PlaybackException) { - if (prerollActive) { + if (prerollActive || fullscreenPlayerParent != null) { prerollActive = false + findViewById(R.id.player_preroll_video_host).animate().cancel() + restoreFullscreenPlayer() prerollView?.visibility = View.GONE playerView?.useController = true } @@ -490,7 +739,11 @@ class PlayerActivity : ComponentActivity() { ) retryJob = lifecycleScope.launch { delay(delayMs) - retryPlayback(refreshSource = nextAttempt >= FRESH_STREAM_RETRY_ATTEMPT) + retryPlayback( + refreshSource = failure.requiresTranscode || + nextAttempt >= FRESH_STREAM_RETRY_ATTEMPT, + forceTranscode = failure.requiresTranscode, + ) } return } @@ -498,7 +751,7 @@ class PlayerActivity : ComponentActivity() { showPlaybackError(failure) } - private fun retryPlayback(refreshSource: Boolean) { + private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) { retryJob?.cancel() hidePlaybackError() val playback = player ?: return @@ -529,6 +782,7 @@ class PlayerActivity : ComponentActivity() { itemId = id, title = playbackTitle, resumePositionMs = positionMs, + forceTranscode = forceTranscode, ) }.onSuccess { refreshed -> if (isFinishing || isDestroyed) return@onSuccess @@ -572,10 +826,12 @@ class PlayerActivity : ComponentActivity() { visibility = View.VISIBLE findViewById(R.id.playback_error_retry).requestFocus() } + alertsSuppressed.value = true } private fun hidePlaybackError() { errorView?.visibility = View.GONE + alertsSuppressed.value = loadingView?.visibility == View.VISIBLE } private fun scheduleRetryBudgetReset() { @@ -680,6 +936,7 @@ class PlayerActivity : ComponentActivity() { text = title.ifBlank { "Now playing" } } if (logoUrl.isNullOrBlank()) { + logo.clearColorFilter() logo.visibility = View.GONE fallback.visibility = View.VISIBLE return @@ -687,7 +944,8 @@ class PlayerActivity : ComponentActivity() { logo.load(logoUrl) { crossfade(false) listener( - onSuccess = { _, _ -> + onSuccess = { _, result -> + makeLogoVisibleOnDarkBackground(logo, result.drawable) logo.visibility = View.VISIBLE fallback.visibility = View.GONE }, @@ -699,6 +957,36 @@ class PlayerActivity : ComponentActivity() { } } + private fun setUpPlaybackIdentity(title: String) { + playbackIdentityView = findViewById(R.id.player_playback_identity) + findViewById(R.id.player_playback_identity_title).apply { + text = title.ifBlank { "Now playing" } + visibility = View.VISIBLE + } + } + + private fun showPlaybackIdentity() { + if (playbackIdentityShown) return + val identity = playbackIdentityView ?: return + playbackIdentityShown = true + playbackIdentityHideJob?.cancel() + identity.animate().cancel() + identity.alpha = 0f + identity.visibility = View.VISIBLE + identity.animate() + .alpha(1f) + .setDuration(PLAYBACK_IDENTITY_FADE_MS) + .start() + playbackIdentityHideJob = lifecycleScope.launch { + delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS) + identity.animate() + .alpha(0f) + .setDuration(PLAYBACK_IDENTITY_FADE_MS) + .withEndAction { identity.visibility = View.GONE } + .start() + } + } + private fun updatePlaybackTiming(playback: Player) { val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L) { @@ -718,6 +1006,210 @@ class PlayerActivity : ComponentActivity() { R.string.player_ends_at, DateFormat.getTimeFormat(this).format(Date(finishAt)), ) + updateTimeRemainingCue(remainingMs) + } + + // --- Time remaining cue ------------------------------------------------------- + + private fun setUpTimeRemainingCue() { + timeRemainingCue = findViewById(R.id.player_time_remaining) + timeRemainingLabel = findViewById(R.id.player_time_remaining_label) + timeRemainingValue = findViewById(R.id.player_time_remaining_value) + } + + private fun setUpSeasonFinaleCue() { + seasonFinaleCue = findViewById(R.id.player_season_finale) + seasonFinaleValue = findViewById(R.id.player_season_finale_value) + } + + private fun prefetchSeasonFinale() { + seasonFinaleJob?.cancel() + seasonFinaleInfo = null + val id = itemId?.takeIf(String::isNotBlank) ?: return + seasonFinaleJob = lifecycleScope.launch { + val finale = runCatching { ServiceLocator.repository.seasonFinale(id) } + .getOrNull() + ?.takeIf { it.seasonFinale } + ?: return@launch + if (itemId != id) return@launch + seasonFinaleInfo = finale + // Usually this is ready before the ordinary start cue. A slow metadata call + // must not lose the news, so show it as soon as the player is otherwise ready. + if (playbackStartCueShown && timingCueCanShow()) showSeasonFinaleCue(finale) + } + } + + private fun showSeasonFinaleCue(finale: GatewaySeasonFinale) { + if (seasonFinaleShown || !finale.seasonFinale) return + val cue = seasonFinaleCue ?: return + seasonFinaleShown = true + seasonFinaleValue?.text = getString( + R.string.player_season_finale_value, + finale.seriesName.ifBlank { playbackTitle }, + finale.seasonNumber, + ) + seasonFinaleHideJob?.cancel() + cue.animate().cancel() + cue.alpha = 0f + cue.translationY = dp(TIME_REMAINING_TRAVEL_DP).toFloat() + cue.visibility = View.VISIBLE + cue.animate() + .alpha(1f) + .translationY(0f) + .setDuration(TIME_REMAINING_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + seasonFinaleHideJob = lifecycleScope.launch { + delay(TIME_REMAINING_VISIBLE_MS) + cue.animate() + .alpha(0f) + .translationY(dp(TIME_REMAINING_TRAVEL_DP).toFloat()) + .setDuration(TIME_REMAINING_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + cue.visibility = View.GONE + cue.alpha = 1f + cue.translationY = 0f + } + .start() + } + } + + /** Counts actual playing time, so buffering and pause do not consume the ten seconds. */ + private fun startPlaybackStartCueWatch() { + playbackStartCueJob?.cancel() + playbackStartCueJob = lifecycleScope.launch { + var playedMs = 0L + var lastTickMs = SystemClock.elapsedRealtime() + while (isActive && !playbackStartCueShown) { + delay(PLAYBACK_START_CUE_TICK_MS) + val nowMs = SystemClock.elapsedRealtime() + if ( + player?.isPlaying == true && + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + ) { + playedMs += nowMs - lastTickMs + } + lastTickMs = nowMs + if (playbackStartCueReady(playedMs, initialResumePositionMs)) { + player?.let(::updatePlaybackStartCue) + } + } + } + } + + private fun updatePlaybackStartCue(playback: Player) { + if (playbackStartCueShown || !timingCueCanShow()) return + val duration = playback.duration + if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return + val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) + if (remainingMs == 0L) return + val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f) + val wallClockRemainingMs = (remainingMs / speed).toLong() + playbackStartCueShown = true + seasonFinaleInfo?.let(::showSeasonFinaleCue) + if (initialResumePositionMs > 0L) { + showTimingCue( + label = getString(R.string.player_resume_time_left_label), + value = formatCueDuration(wallClockRemainingMs), + ) + } else { + val finishAt = System.currentTimeMillis() + wallClockRemainingMs + showTimingCue( + label = getString(R.string.player_finishes_in_label), + value = getString( + R.string.player_finishes_in_value, + formatCueDuration(wallClockRemainingMs), + DateFormat.getTimeFormat(this).format(Date(finishAt)), + ), + ) + } + } + + /** + * Announces the ten-minute boundary once per item. Media time is intentional: pause + * freezes the boundary and playback speed does not make a ten-minute story segment + * become five minutes of content. + */ + private fun updateTimeRemainingCue(remainingMs: Long) { + if (!playbackStartCueShown || timeRemainingShown || timeRemainingHideJob?.isActive == true) return + if (ServiceLocator.settings.current?.showTenMinuteReminder == false) return + val minutes = timeRemainingCueMinutes(remainingMs) ?: return + if (!timingCueCanShow()) return + timeRemainingShown = true + showTimingCue( + label = getString(R.string.player_time_remaining_label), + value = resources.getQuantityString( + R.plurals.player_time_remaining_cue, + minutes, + minutes, + ), + ) + } + + private fun timingCueCanShow(): Boolean = + !prerollActive && + prerollView?.isVisible != true && + loadingView?.isVisible != true && + errorView?.isVisible != true + + private fun showTimingCue(label: String, value: String) { + val cue = timeRemainingCue ?: return + timeRemainingLabel?.text = label + timeRemainingValue?.text = value + timeRemainingHideJob?.cancel() + cue.animate().cancel() + cue.alpha = 0f + cue.translationY = dp(TIME_REMAINING_TRAVEL_DP).toFloat() + cue.visibility = View.VISIBLE + cue.animate() + .alpha(1f) + .translationY(0f) + .setDuration(TIME_REMAINING_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + + timeRemainingHideJob = lifecycleScope.launch { + delay(TIME_REMAINING_VISIBLE_MS) + cue.animate() + .alpha(0f) + .translationY(dp(TIME_REMAINING_TRAVEL_DP).toFloat()) + .setDuration(TIME_REMAINING_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + cue.visibility = View.GONE + cue.alpha = 1f + cue.translationY = 0f + } + .start() + } + } + + private fun resetTimeRemainingCue() { + playbackStartCueJob?.cancel() + playbackStartCueJob = null + timeRemainingHideJob?.cancel() + timeRemainingHideJob = null + playbackStartCueShown = false + timeRemainingShown = false + seasonFinaleJob?.cancel() + seasonFinaleJob = null + seasonFinaleHideJob?.cancel() + seasonFinaleHideJob = null + seasonFinaleInfo = null + seasonFinaleShown = false + seasonFinaleCue?.apply { + animate().cancel() + visibility = View.GONE + alpha = 1f + translationY = 0f + } + timeRemainingCue?.apply { + animate().cancel() + visibility = View.GONE + alpha = 1f + translationY = 0f + } } // --- Next up ------------------------------------------------------------------ @@ -880,13 +1372,52 @@ class PlayerActivity : ComponentActivity() { ?.start() } + private fun handlePlaybackEnded() { + when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) { + PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext) + PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion() + } + } + + /** + * A terminal item should never leave a still frame or black player surface behind. + * Starting a fresh Home task also covers playback launched from the screensaver and + * deliberately clears any detail/search overlay that was behind PlayerActivity. + */ + private fun returnHomeAfterCompletion() { + if (returningHomeAfterCompletion || isFinishing || isDestroyed) return + returningHomeAfterCompletion = true + nextUpJob?.cancel() + progressJob?.cancel() + hideNextUp() + + val playback = player + val completedItemId = itemId + if (!completedItemId.isNullOrBlank() && playbackStarted && !stopReported) { + stopReported = true + PlaybackStopWorker.enqueue( + this, + playbackSession(completedItemId), + playback?.currentPosition ?: 0L, + ) + } + startActivity( + Intent(this, MainActivity::class.java).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + ) + finish() + } + /** * Rolls into [next] inside the running player rather than relaunching the activity: * no teardown, no black frame between episodes. The outgoing episode is reported * stopped first so Emby records it as finished. */ private fun playNext(next: NextEpisode) { - if (advancing) return + if (advancing || returningHomeAfterCompletion) return advancing = true nextUpJob?.cancel() progressJob?.cancel() @@ -910,8 +1441,17 @@ class PlayerActivity : ComponentActivity() { availableSubtitles = next.subtitles stopReported = false playbackStarted = false + playbackIdentityShown = false + playbackIdentityHideJob?.cancel() + playbackIdentityView?.apply { + animate().cancel() + alpha = 0f + visibility = View.GONE + } + initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L) renderedFirstFrame = false automaticRetryAttempt = 0 + resetTimeRemainingCue() nextEpisode = null nextUpDismissed = false requestStartedAtMs = SystemClock.elapsedRealtime() @@ -920,6 +1460,7 @@ class PlayerActivity : ComponentActivity() { playbackTitle = nextTitle(next) bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) + setUpPlaybackIdentity(title = playbackTitle) hidePlaybackError() showPlaybackLoading() playback?.apply { @@ -947,6 +1488,32 @@ class PlayerActivity : ComponentActivity() { finish() } + private fun bindPauseOverlay(view: PlayerView) { + pauseOverlay = view.findViewById(R.id.player_pause_overlay) + nowPlayingGroup = view.findViewById(R.id.player_now_playing_group) + pauseOverlay?.findViewById(R.id.player_pause_title)?.text = playbackTitle + pauseOverlay?.findViewById(R.id.player_pause_overview)?.apply { + text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) } + } + pauseOverlay?.findViewById(R.id.player_pause_poster)?.apply { + val poster = pausePosterUrl + if (poster.isNullOrBlank()) { + visibility = View.GONE + } else { + visibility = View.VISIBLE + load(poster) { crossfade(true) } + } + } + } + + private fun updatePauseOverlay(playback: Player) { + val paused = playbackStarted && !prerollActive && + playback.playbackState == Player.STATE_READY && !playback.isPlaying + pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE + nowPlayingGroup?.visibility = if (paused) View.GONE else View.VISIBLE + if (paused) playerView?.showController() + } + // Activity.dispatchKeyEvent is public platform API; the @RestrictTo lives on the // androidx.core.app.ComponentActivity override we inherit, so overriding it is safe. @SuppressLint("RestrictedApi") @@ -1021,6 +1588,31 @@ class PlayerActivity : ComponentActivity() { .show() } + /** + * Hosts the launcher's service-alert bar over the video. + * + * News about the server is worth more here than anywhere else — playback direct-plays + * from Emby, so "the server has stopped communicating" explains a stall the viewer is + * looking at right now. The bar is never focusable, so the transport controls keep the + * remote; it times itself out rather than asking for a press. + * + * [alertsSuppressed] is what stops an alert being *used up* behind the loading or + * error overlay: the banner records an alert as seen the moment it composes, and the + * whole design is that an alert nobody could see waits instead. + */ + private fun mountServiceAlerts() { + findViewById(R.id.player_service_alerts)?.apply { + // The view outlives compositions across the activity's own lifecycle, and + // the player is not a Compose screen — dispose with the view, not the window. + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MembyTheme { + ServiceAlertBanner(suppressed = alertsSuppressed.value) + } + } + } + } + private fun selectedPictureMode(): PictureMode { val key = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) .getString(PICTURE_MODE_KEY, PictureMode.AUTO.key) @@ -1424,6 +2016,9 @@ class PlayerActivity : ComponentActivity() { override fun onStart() { super.onStart() + if (prerollActive) { + player?.playWhenReady = true + } beginContentWhenReady() if (stoppedInBackground && playbackStarted) { stoppedInBackground = false @@ -1451,10 +2046,17 @@ class PlayerActivity : ComponentActivity() { progressJob?.cancel() prerollTimerJob?.cancel() prerollScheduleJob?.cancel() + playbackStartCueJob?.cancel() + timeRemainingHideJob?.cancel() + seasonFinaleJob?.cancel() + seasonFinaleHideJob?.cancel() + prerollView?.findViewById(R.id.player_preroll_video_host)?.animate()?.cancel() castJob?.cancel() nextUpJob?.cancel() retryJob?.cancel() stablePlaybackJob?.cancel() + playbackIdentityHideJob?.cancel() + playbackIdentityView?.animate()?.cancel() loadingAnimator?.cancel() bufferingStartedAtMs?.let { totalBufferingMs += SystemClock.elapsedRealtime() - it } Log.i( @@ -1489,13 +2091,26 @@ class PlayerActivity : ComponentActivity() { private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) { val id = itemId?.takeIf { it.isNotBlank() } ?: return lifecycleScope.launch { + val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L + val evaluatesAutoFollow = !autoFollowEvaluated && + durationMs > 0L && positionMs >= (durationMs + 1L) / 2L runCatching { ServiceLocator.repository.reportPlaybackProgress( playbackSession(id), positionMs, isPaused, eventName, + durationMs.takeUnless { autoFollowEvaluated } ?: 0L, ) + }.onSuccess { showTitle -> + if (evaluatesAutoFollow) autoFollowEvaluated = true + showTitle?.let { + Toast.makeText( + this@PlayerActivity, + "$it was added to My Shows", + Toast.LENGTH_LONG, + ).show() + } } } } @@ -1550,6 +2165,12 @@ class PlayerActivity : ComponentActivity() { private const val EXTRA_TITLE = "extra_title" private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms" private const val EXTRA_LOGO_URL = "extra_logo_url" + private const val EXTRA_OVERVIEW = "extra_overview" + private const val EXTRA_EPISODE_CODE = "extra_episode_code" + private const val EXTRA_RUNTIME_MS = "extra_runtime_ms" + private const val EXTRA_PREROLL_ENABLED = "extra_preroll_enabled" + private const val EXTRA_PREROLL_DURATION_MS = "extra_preroll_duration_ms" + private const val EXTRA_POSTER_URL = "extra_poster_url" private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms" private const val EXTRA_SUBTITLES = "extra_subtitles" private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id" @@ -1575,6 +2196,12 @@ class PlayerActivity : ComponentActivity() { title: String?, resumePositionMs: Long = 0L, logoUrl: String? = null, + overview: String? = null, + episodeCode: String? = null, + runtimeMs: Long = 0L, + prerollEnabled: Boolean = true, + prerollDurationMs: Long = DEFAULT_PREROLL_DURATION_MS, + posterUrl: String? = null, subtitles: List = emptyList(), mediaSourceId: String = "", playSessionId: String = "", @@ -1587,6 +2214,12 @@ class PlayerActivity : ComponentActivity() { putExtra(EXTRA_TITLE, title) putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs) logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) } + overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) } + episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) } + putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L)) + putExtra(EXTRA_PREROLL_ENABLED, prerollEnabled) + putExtra(EXTRA_PREROLL_DURATION_MS, prerollDurationMs) + posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) } if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, subtitleJson.encodeToString(subtitles)) putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) @@ -1621,7 +2254,24 @@ class PlayerActivity : ComponentActivity() { } private const val PROGRESS_INTERVAL_MS = 10_000L - private const val PREROLL_SECONDS = 5 + private const val PLAYBACK_START_CUE_DELAY_MS = 10_000L + private const val RESUME_TIME_LEFT_CUE_DELAY_MS = 2_000L + private const val PLAYBACK_START_CUE_TICK_MS = 250L + private const val TIME_REMAINING_THRESHOLD_MS = 10L * 60_000L + private const val TIME_REMAINING_VISIBLE_MS = 6_000L + private const val TIME_REMAINING_ANIMATION_MS = 240L + private const val TIME_REMAINING_TRAVEL_DP = 12 + internal const val PLAYBACK_IDENTITY_VISIBLE_MS = 5_000L + private const val PLAYBACK_IDENTITY_FADE_MS = 250L + internal const val DEFAULT_PREROLL_DURATION_MS = 6_500L + private const val MIN_PREROLL_DURATION_MS = 1_000L + private const val MAX_PREROLL_DURATION_MS = 30_000L + private const val PREROLL_CALENDAR_SIZE = 8 + private const val PREROLL_CALENDAR_COLUMNS = 4 + private const val PREROLL_CALENDAR_CARD_HEIGHT_DP = 80 + private const val PREROLL_TIMER_TICK_MS = 100L + private const val PREROLL_FADE_MS = 180L + private const val PREROLL_TRANSITION_MS = 480L private const val MAX_CAST_MEMBERS = 16 private val PREROLL_BLOCKED_KEYS = setOf( KeyEvent.KEYCODE_DPAD_CENTER, @@ -1669,6 +2319,35 @@ class PlayerActivity : ComponentActivity() { else -> "${totalMinutes / 60L} hr ${totalMinutes % 60L} min remaining" } } + + internal fun timeRemainingCueMinutes(remainingMs: Long): Int? { + if (remainingMs <= 0L || remainingMs >= TIME_REMAINING_THRESHOLD_MS) return null + return ceil(remainingMs / 60_000.0).toInt().coerceAtLeast(1) + } + + internal fun playbackStartCueReady(playedMs: Long, resumePositionMs: Long = 0L): Boolean = + playedMs >= if (resumePositionMs > 0L) { + RESUME_TIME_LEFT_CUE_DELAY_MS + } else { + PLAYBACK_START_CUE_DELAY_MS + } + + internal fun formatCueDuration(durationMs: Long): String { + val minutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong().coerceAtLeast(1L) + return if (minutes == 1L) "1 min" else "$minutes mins" + } + + internal fun formatPrerollRuntime(durationMs: Long): String? { + if (durationMs <= 0L || durationMs == C.TIME_UNSET) return null + val minutes = ceil(durationMs / 60_000.0).toLong().coerceAtLeast(1L) + val hours = minutes / 60L + val remainder = minutes % 60L + return when { + hours == 0L -> "$minutes mins" + remainder == 0L -> "${hours}h" + else -> "${hours}h ${remainder}m" + } + } } } @@ -1678,6 +2357,25 @@ internal fun prerollCanHandOff( lifecycleStarted: Boolean, ): Boolean = active && minimumElapsed && lifecycleStarted +/** A positive position means the viewer is continuing something already started. */ +internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): Boolean = + enabled && resumePositionMs <= 0L + +internal enum class PlaybackCompletionAction { + PLAY_NEXT, + RETURN_HOME, +} + +internal fun playbackCompletionAction( + hasNextEpisode: Boolean, + nextUpDismissed: Boolean, +): PlaybackCompletionAction = + if (hasNextEpisode && !nextUpDismissed) { + PlaybackCompletionAction.PLAY_NEXT + } else { + PlaybackCompletionAction.RETURN_HOME + } + internal fun shouldZoomVideo( modeKey: String, width: Int, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt new file mode 100644 index 0000000..a36134e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt @@ -0,0 +1,75 @@ +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.util.AttributeSet +import android.util.TypedValue +import android.view.View +import kotlin.math.min + +/** + * Compact TV-safe countdown that does not depend on a continuously running animator. + * PlayerActivity advances it only while video is actually playing, keeping the ring and + * the pre-roll gate on the same clock. + */ +class PrerollCountdownView @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 { + color = Color.argb(72, 255, 255, 255) + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeWidth = 5f * density + } + private val progressPaint = Paint(trackPaint).apply { + color = Color.rgb(82, 190, 75) + } + private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + 30f, + resources.displayMetrics, + ) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + } + + private var seconds = 7 + private var progress = 1f + + fun setCountdown(seconds: Int, progress: Float, description: String) { + this.seconds = seconds.coerceAtLeast(0) + this.progress = progress.coerceIn(0f, 1f) + contentDescription = description + 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) { + canvas.drawArc(ringBounds, -90f, 360f * progress, false, progressPaint) + } + val baseline = height / 2f - (numberPaint.ascent() + numberPaint.descent()) / 2f + canvas.drawText(seconds.toString(), width / 2f, baseline, numberPaint) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index 9c9c7f3..0e182b2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -79,8 +78,6 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.get import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -88,13 +85,13 @@ import androidx.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage -import coil.imageLoader import coil.request.ImageRequest -import coil.request.SuccessResult import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.useTextTitleForLogo +import com.ponzischeme89.memby.ui.visibleWithWatchedPreference import com.ponzischeme89.memby.ui.settings.SettingsSheet import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -157,6 +154,7 @@ fun ScreensaverContent( ringColor = ringColorFromHex(settings?.ringColorHex), embyServerId = settings?.serverId, warmBackdropUrl = settings?.lastBackdropUrl, + hideWatchedMovies = settings?.hideWatchedMovies == true, startupMessage = startupMessage, ) } @@ -171,6 +169,7 @@ private fun Slideshow( ringColor: Color, embyServerId: String?, warmBackdropUrl: String?, + hideWatchedMovies: Boolean, startupMessage: String?, ) { val repo = ServiceLocator.repository @@ -200,12 +199,12 @@ private fun Slideshow( val slideProgress = remember { mutableFloatStateOf(0f) } val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(reloadKey) { + LaunchedEffect(reloadKey, hideWatchedMovies) { loading = true loadError = null runCatching { repo.getScreensaverItems() } .onSuccess { fetched -> - val queue = fetched.shuffled() + val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled() // The lightweight startup request may already be on-screen. Keep that // exact item at the front rather than swapping through several results // as competing requests finish; the rest of the random queue follows it. @@ -224,10 +223,14 @@ private fun Slideshow( // Do not wait for the full 200-item queue before showing a first real backdrop. // This runs alongside it and normally wins on a cold launch. - LaunchedEffect(reloadKey) { + LaunchedEffect(reloadKey, hideWatchedMovies) { runCatching { repo.getStartupBackdropMovie() } .onSuccess { movie -> - if (movie != null && items.isEmpty()) { + if ( + movie != null && + visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() && + items.isEmpty() + ) { items = listOf(movie) loading = false } @@ -244,7 +247,10 @@ private fun Slideshow( loadingMore = true toast = "Finding more from your library…" scope.launch { - val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()) + val more = visibleWithWatchedPreference( + runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()), + hideWatchedMovies, + ) if (more.isNotEmpty()) { val existing = items.mapTo(HashSet()) { it.id } val fresh = more.filter { it.id !in existing } @@ -616,7 +622,6 @@ private fun Slideshow( if (settingsOpen) { SettingsSheet( - editableServer = true, onClose = { settingsOpen = false }, onInstallerLaunched = onExit, ) @@ -863,49 +868,6 @@ private fun InfoAndActions( } } -/** - * Transparent Emby logos are commonly black. They disappear over a dark backdrop, so - * inspect a small decoded copy and retain the text title when its visible pixels are - * overwhelmingly dark. Until the image has been inspected, text is the safe default. - */ -@Composable -private fun useTextTitleForLogo(logoUrl: String?): Boolean { - if (logoUrl == null) return true - val context = LocalContext.current - val isDark by produceState(initialValue = true, logoUrl) { - value = runCatching { - val result = context.imageLoader.execute( - ImageRequest.Builder(context) - .data(logoUrl) - .allowHardware(false) - .size(64, 64) - .build(), - ) as? SuccessResult ?: return@runCatching true - isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64)) - }.getOrDefault(true) - } - return isDark -} - -private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean { - var opaquePixels = 0 - var darkPixels = 0 - for (y in 0 until bitmap.height step 2) { - for (x in 0 until bitmap.width step 2) { - val pixel = bitmap[x, y] - if (android.graphics.Color.alpha(pixel) < 48) continue - opaquePixels++ - val luminance = ( - android.graphics.Color.red(pixel) * 0.2126f + - android.graphics.Color.green(pixel) * 0.7152f + - android.graphics.Color.blue(pixel) * 0.0722f - ) - if (luminance < 58f) darkPixels++ - } - } - return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f -} - @Composable private fun StatusChips(item: BaseItem, isFavorite: Boolean) { val chips = buildList { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 9ef088c..9b1488b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -85,6 +85,7 @@ import coil.imageLoader import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.PosterGridCard @@ -166,7 +167,7 @@ fun SearchScreen( state.errorMessage != null && state.results.isEmpty() -> true state.isDiscovery -> discoveryItems.isNotEmpty() || state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE } - else -> state.results.isNotEmpty() + else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() } LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } @@ -237,6 +238,7 @@ fun SearchScreen( }, onItemSelected = onItemSelected, onRetry = viewModel::retry, + onRequest = viewModel::request, onSuggestionSelected = viewModel::onQueryChanged, modifier = Modifier.fillMaxHeight(), ) @@ -588,6 +590,7 @@ private fun ResultsPane( onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, onRetry: () -> Unit, + onRequest: (GatewayRequestCandidate) -> Unit, onSuggestionSelected: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -644,6 +647,13 @@ private fun ResultsPane( resultsEntry = resultsEntry, keyboardReturn = keyboardReturn, ) + !showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() -> + RequestOptions( + state = state, + resultsEntry = resultsEntry, + keyboardReturn = keyboardReturn, + onRequest = onRequest, + ) items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery) else -> ResultsGrid( items = items, @@ -756,6 +766,54 @@ private fun ResultsGrid( } } +@Composable +private fun RequestOptions( + state: SearchUiState, + resultsEntry: FocusRequester, + keyboardReturn: FocusRequester, + onRequest: (GatewayRequestCandidate) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Not in your library? Request it:", + color = Muted, + fontSize = 16.sp, + ) + state.requestCandidates.take(6).forEachIndexed { index, candidate -> + val busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}" + val label = when { + candidate.alreadyAdded -> "${candidate.title} — already requested" + busy -> "Requesting ${candidate.title}…" + else -> "Request ${if (candidate.mediaType == "movie") "movie" else "show"}: " + + candidate.title + candidate.year.takeIf { it > 0 }?.let { " ($it)" }.orEmpty() + } + FocusScaleContainer( + onFocused = {}, + onClick = { onRequest(candidate) }, + contentDescription = label, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier) + .focusProperties { left = keyboardReturn }, + ) { focused -> + Text( + label, + color = if (focused) KeyLabelFocused else KeyLabel, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(if (focused) KeyFocused else KeyIdle) + .padding(horizontal = 18.dp, vertical = 12.dp), + ) + } + } + state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) } + } +} + private val GenreColors = listOf( Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6), Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76), @@ -861,6 +919,7 @@ private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) val message = when { showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off." state.isLoading -> "Searching…" + state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…" else -> "Nothing in this library matches that. Try fewer letters, or a different spelling." } Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp)) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index d1af0aa..81319be 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -35,6 +36,10 @@ data class SearchUiState( /** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */ val hasSearched: Boolean = false, val errorMessage: String? = null, + val requestCandidates: List = emptyList(), + val requestLookupLoading: Boolean = false, + val requestingCandidateKey: String? = null, + val requestMessage: String? = null, ) { /** The query is long enough to search but nothing came back. */ val isEmptyResult: Boolean @@ -100,7 +105,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { /** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */ fun onQueryChanged(query: String) { // The visible field updates immediately; only the *search* is debounced. - _state.update { it.copy(query = query) } + _state.update { + it.copy( + query = query, + requestCandidates = emptyList(), + requestLookupLoading = false, + requestMessage = null, + ) + } queryFlow.value = query } @@ -111,7 +123,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { fun clearQuery() { // Clear immediately so results from a genre tile cannot remain visible while the // debounced empty-query transition is pending. - _state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) } + _state.update { + it.copy( + query = "", results = emptyList(), isLoading = false, hasSearched = false, + errorMessage = null, requestCandidates = emptyList(), + requestLookupLoading = false, requestMessage = null, + ) + } queryFlow.value = "" } @@ -123,6 +141,38 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { viewModelScope.launch { runSearch(term) } } + fun request(candidate: GatewayRequestCandidate) { + if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return + val candidateKey = "${candidate.mediaType}:${candidate.foreignId}" + _state.update { + it.copy(requestingCandidateKey = candidateKey, requestMessage = null) + } + viewModelScope.launch { + runCatching { repository.requestMedia(candidate) } + .onSuccess { title -> + _state.update { + it.copy( + requestingCandidateKey = null, + requestCandidates = it.requestCandidates.map { option -> + if (option.mediaType == candidate.mediaType && + option.foreignId == candidate.foreignId + ) option.copy(alreadyAdded = true) else option + }, + requestMessage = "${title.ifBlank { candidate.title }} was requested.", + ) + } + } + .onFailure { error -> + _state.update { + it.copy( + requestingCandidateKey = null, + requestMessage = friendlyEmbyError(error), + ) + } + } + } + } + /** * Genre chips for the empty state, taken from items the home screen already loaded. * Nothing is fetched: if home has no data yet, the chips simply do not appear. @@ -149,7 +199,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // Back to the discovery state, but the previous results are dropped rather // than left behind a shorter query they no longer match. _state.update { - it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) + it.copy( + results = emptyList(), isLoading = false, hasSearched = false, + errorMessage = null, requestCandidates = emptyList(), + requestLookupLoading = false, requestMessage = null, + ) } return } @@ -158,6 +212,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null) } + if (cached.isEmpty()) loadRequestCandidates(term) return } @@ -166,13 +221,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { it.copy(isLoading = true, errorMessage = null) } runCatching { repository.search(term) } .onSuccess { items -> - val ranked = rankSearchResults(term, items) + // Gateway payloads carry the backend ranker's score. Preserve that + // ordering exactly; direct-to-Emby mode keeps the local textual fallback. + val ranked = if (items.any { it.membyRecommendationScore != null }) { + items + } else { + rankSearchResults(term, items) + } cache[term] = ranked rememberQuery(term) viewModelScope.launch { repository.recordSearch(term) } _state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) } + if (ranked.isEmpty()) loadRequestCandidates(term) } .onFailure { error -> // A cancelled search is the normal case while typing, not a failure. @@ -183,6 +245,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } } + private suspend fun loadRequestCandidates(term: String) { + _state.update { it.copy(requestLookupLoading = true) } + val candidates = runCatching { repository.lookupMediaRequests(term) } + .getOrElse { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + emptyList() + } + if (_state.value.query.trim() == term) { + _state.update { + it.copy(requestCandidates = candidates, requestLookupLoading = false) + } + } + } + private fun rememberQuery(term: String) { recentQueries.removeAll { it.equals(term, ignoreCase = true) } recentQueries.addFirst(term) 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 961fd14..839f8e5 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 @@ -1,5 +1,16 @@ +/* + * Copyright (C) 2026 Memby contributors + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package com.ponzischeme89.memby.ui.settings +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -7,6 +18,7 @@ import androidx.compose.animation.slideInHorizontally 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 @@ -23,15 +35,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.TagFaces import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,6 +60,7 @@ 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.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color @@ -53,25 +68,33 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.Button import androidx.tv.material3.Text +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.model.GatewayDevice import com.ponzischeme89.memby.ui.PreviewSurface import com.ponzischeme89.memby.ui.TvPreview +import com.ponzischeme89.memby.ui.WelcomeQuoteStyle import com.ponzischeme89.memby.update.UpdateChecker import com.ponzischeme89.memby.update.UpdateStatus import kotlinx.coroutines.delay +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout private data class ChoiceOption(val value: String, val label: String, val color: Color? = null) @@ -87,10 +110,36 @@ private val DensityOptions = listOf( ChoiceOption("large", "Large"), ) +private val ArtworkOptions = listOf( + ChoiceOption("automatic", "Automatic"), + ChoiceOption("poster", "Posters"), + ChoiceOption("backdrop", "Backdrops"), +) + +private val WelcomeOptions = WelcomeQuoteStyle.entries.map { + ChoiceOption(it.value, it.label) +} + +internal enum class SettingsPage( + val label: String, + val description: String, + val icon: ImageVector, + val showInRail: Boolean = true, +) { + APPEARANCE("Appearance", "Artwork and accents", Icons.Default.Palette), + PLAYBACK("Playback", "Reminders and episodes", Icons.Default.PlayArrow), + HOME("Home screen", "Rows and cards", Icons.Default.Home), + WELCOME("Welcome", "Greeting personality", Icons.Default.TagFaces), + UPDATES("Updates", "Version status", Icons.Default.SystemUpdate), + DEVICES("Devices", "Signed-in TVs", Icons.Default.Devices), + ABOUT("About", "Memby for Android TV", Icons.Default.Info), + LICENSES("Licences", "Source and open-source terms", Icons.Default.Description, showInRail = false), +} + private val EmbyGreen = Color(0xFF52B54B) private val Canvas = Color(0xFF090B0D) -private val Panel = Color(0xFF101418) -private val SectionSurface = Color(0xFF151B20) +private val Panel = Color(0xFF0D1114) +private val SectionSurface = Color(0xFF12171B) private val RowFocused = Color(0xFF20282F) private val ControlIdle = Color(0xFF252D34) private val TextPrimary = Color(0xFFF2F5F7) @@ -101,33 +150,49 @@ private val Hairline = Color.White.copy(alpha = 0.09f) internal data class SettingsPanelState( val showLogo: Boolean = true, val autoPlayNext: Boolean = true, + val showTenMinuteReminder: Boolean = true, val ringColor: String = "52B54B", val homeSections: Set = setOf("continue", "favorites", "latest"), val cardDensity: String = "standard", + val artworkStyle: String = "automatic", + val hiddenHomeRowCount: Int = 0, val showCardMetadata: Boolean = true, - val editableServer: Boolean = true, - val baseUrl: String = "", - val repoPath: String = "", - val token: String = "", + val hideWatchedMovies: Boolean = false, + val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value, + val selectedPage: SettingsPage = SettingsPage.APPEARANCE, val checking: Boolean = false, val updateStatus: UpdateStatus? = null, val installMessage: String? = null, val installedVersion: String = "", + val devices: List = emptyList(), + val devicesLoading: Boolean = false, + val devicesError: String? = null, + val removingDeviceId: String? = null, + val pendingRemovalDeviceId: String? = null, ) internal data class SettingsPanelActions( val onClose: () -> Unit = {}, val onShowLogoChanged: (Boolean) -> Unit = {}, val onAutoPlayNextChanged: (Boolean) -> Unit = {}, + val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {}, val onRingColorChanged: (String) -> Unit = {}, val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> }, val onCardDensityChanged: (String) -> Unit = {}, + val onArtworkStyleChanged: (String) -> Unit = {}, + val onRestoreHiddenRows: () -> Unit = {}, val onShowCardMetadataChanged: (Boolean) -> Unit = {}, - val onBaseUrlChanged: (String) -> Unit = {}, - val onRepoPathChanged: (String) -> Unit = {}, - val onTokenChanged: (String) -> Unit = {}, + val onHideWatchedMoviesChanged: (Boolean) -> Unit = {}, + val onWelcomeQuoteStyleChanged: (String) -> Unit = {}, + val onPageSelected: (SettingsPage) -> Unit = {}, val onCheckForUpdates: () -> Unit = {}, val onInstallUpdate: (UpdateStatus.Available) -> Unit = {}, + val onRefreshDevices: () -> Unit = {}, + val onRenameDevice: (GatewayDevice) -> Unit = {}, + val onRemoveDevice: (GatewayDevice) -> Unit = {}, + val onCancelDeviceRemoval: () -> Unit = {}, + val onOpenSourceCode: () -> Unit = {}, + val onOpenLicenses: () -> Unit = {}, ) /** @@ -137,10 +202,9 @@ internal data class SettingsPanelActions( */ @Composable fun SettingsSheet( - editableServer: Boolean, onClose: () -> Unit, modifier: Modifier = Modifier, - overlay: Boolean = true, + overlay: Boolean = false, onInstallerLaunched: (() -> Unit)? = null, ) { val context = LocalContext.current @@ -151,37 +215,77 @@ fun SettingsSheet( var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) } + var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) } var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } - var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) } - var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) } - var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) } var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) } var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } + var artworkStyle by rememberSaveable { mutableStateOf(settings.homeArtworkStyle) } var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) } + var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) } + var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) } + var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) } var checking by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } var installMessage by remember { mutableStateOf(null) } + var devices by remember { mutableStateOf>(emptyList()) } + var devicesLoading by remember { mutableStateOf(false) } + var devicesError by remember { mutableStateOf(null) } + var removingDeviceId by remember { mutableStateOf(null) } + var pendingRemovalDeviceId by remember { mutableStateOf(null) } + var editingDevice by remember { mutableStateOf(null) } + var deviceJob by remember { mutableStateOf(null) } + + BackHandler(enabled = selectedPage == SettingsPage.LICENSES) { + selectedPage = SettingsPage.ABOUT + } + + suspend fun refreshDevices() { + devicesLoading = true + devicesError = null + try { + devices = withTimeout(8_000L) { ServiceLocator.repository.devices() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + devicesError = "Couldn’t load signed-in devices. You can continue using Settings." + } finally { + devicesLoading = false + } + } + + LaunchedEffect(selectedPage, settings.token) { + deviceJob?.cancel() + pendingRemovalDeviceId = null + if (selectedPage == SettingsPage.DEVICES && settings.isSignedIn) { + refreshDevices() + } else { + editingDevice = null + removingDeviceId = null + } + } LaunchedEffect( settings.showTitleLogo, settings.ringColorHex, - settings.updateBaseUrl, - settings.updateRepo, - settings.updateToken, settings.homeSections, settings.homeCardDensity, + settings.homeArtworkStyle, settings.showHomeCardMetadata, + settings.hideWatchedMovies, settings.autoPlayNextEpisode, + settings.showTenMinuteReminder, + settings.welcomeQuoteStyle, ) { showLogo = settings.showTitleLogo autoPlayNext = settings.autoPlayNextEpisode + showTenMinuteReminder = settings.showTenMinuteReminder ringColor = settings.ringColorHex - baseUrl = settings.updateBaseUrl.orEmpty() - repoPath = settings.updateRepo.orEmpty() - token = settings.updateToken.orEmpty() homeSections = settings.homeSections.split(',').toSet() cardDensity = settings.homeCardDensity + artworkStyle = settings.homeArtworkStyle showCardMetadata = settings.showHomeCardMetadata + hideWatchedMovies = settings.hideWatchedMovies + welcomeQuoteStyle = settings.welcomeQuoteStyle } val firstFocus = remember { FocusRequester() } @@ -195,18 +299,25 @@ fun SettingsSheet( val state = SettingsPanelState( showLogo = showLogo, autoPlayNext = autoPlayNext, + showTenMinuteReminder = showTenMinuteReminder, ringColor = ringColor, homeSections = homeSections, cardDensity = cardDensity, + artworkStyle = artworkStyle, + hiddenHomeRowCount = settings.homeHiddenRows.lineSequence().count { it.isNotBlank() }, showCardMetadata = showCardMetadata, - editableServer = editableServer, - baseUrl = baseUrl, - repoPath = repoPath, - token = token, + hideWatchedMovies = hideWatchedMovies, + welcomeQuoteStyle = welcomeQuoteStyle, + selectedPage = selectedPage, checking = checking, updateStatus = status, installMessage = installMessage, installedVersion = checker.installedVersion, + devices = devices, + devicesLoading = devicesLoading, + devicesError = devicesError, + removingDeviceId = removingDeviceId, + pendingRemovalDeviceId = pendingRemovalDeviceId, ) val actions = SettingsPanelActions( onClose = onClose, @@ -218,6 +329,10 @@ fun SettingsSheet( autoPlayNext = it scope.launch { store.setAutoPlayNextEpisode(it) } }, + onShowTenMinuteReminderChanged = { + showTenMinuteReminder = it + scope.launch { store.setShowTenMinuteReminder(it) } + }, onRingColorChanged = { ringColor = it scope.launch { store.setRingColor(it) } @@ -230,24 +345,42 @@ fun SettingsSheet( cardDensity = it scope.launch { store.setHomeCardDensity(it) } }, + onArtworkStyleChanged = { + artworkStyle = it + scope.launch { store.setHomeArtworkStyle(it) } + }, + onRestoreHiddenRows = { + scope.launch { + store.setHomeRowPreferences( + settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(), + settings.homePinnedRows.lineSequence().filter { it.isNotBlank() }.toSet(), + emptySet(), + ) + } + }, onShowCardMetadataChanged = { showCardMetadata = it scope.launch { store.setShowHomeCardMetadata(it) } }, - onBaseUrlChanged = { baseUrl = it; status = null }, - onRepoPathChanged = { repoPath = it; status = null }, - onTokenChanged = { token = it; status = null }, + onHideWatchedMoviesChanged = { + hideWatchedMovies = it + scope.launch { store.setHideWatchedMovies(it) } + }, + onWelcomeQuoteStyleChanged = { + welcomeQuoteStyle = it + scope.launch { store.setWelcomeQuoteStyle(it) } + }, + onPageSelected = { selectedPage = it }, onCheckForUpdates = { if (!checking) { checking = true status = null installMessage = null scope.launch { - if (editableServer) store.setUpdateConfig(baseUrl, repoPath, token) status = checker.check( - settings.updateBaseUrl.orEmpty().ifEmpty { baseUrl }, - settings.updateRepo.orEmpty().ifEmpty { repoPath }, - settings.updateToken.orEmpty().ifEmpty { token }, + settings.updateBaseUrl.orEmpty(), + settings.updateRepo.orEmpty(), + settings.updateToken.orEmpty(), ) checking = false } @@ -258,7 +391,7 @@ fun SettingsSheet( scope.launch { val result = checker.downloadAndInstall( available.apkUrl, - token.ifEmpty { settings.updateToken.orEmpty() }, + settings.updateToken.orEmpty(), ) result.exceptionOrNull()?.let { installMessage = it.message @@ -268,6 +401,49 @@ fun SettingsSheet( } } }, + onRefreshDevices = { + deviceJob?.cancel() + deviceJob = scope.launch { refreshDevices() } + }, + onRenameDevice = { + devicesError = null + editingDevice = it + pendingRemovalDeviceId = null + }, + onRemoveDevice = removeDevice@{ device -> + if (device.current || removingDeviceId != null) return@removeDevice + if (pendingRemovalDeviceId != device.deviceId) { + pendingRemovalDeviceId = device.deviceId + } else { + removingDeviceId = device.deviceId + devicesError = null + deviceJob?.cancel() + deviceJob = scope.launch { + try { + withTimeout(8_000L) { ServiceLocator.repository.removeDevice(device.deviceId) } + devices = devices.filterNot { it.deviceId == device.deviceId } + pendingRemovalDeviceId = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + devicesError = "Couldn’t remove ${device.deviceName}." + } finally { + removingDeviceId = null + } + } + } + }, + onCancelDeviceRemoval = { pendingRemovalDeviceId = null }, + onOpenSourceCode = { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.SOURCE_CODE_URL)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + }, + onOpenLicenses = { selectedPage = SettingsPage.LICENSES }, ) Box(modifier = modifier.fillMaxSize()) { @@ -290,7 +466,11 @@ fun SettingsSheet( AnimatedVisibility( visible = shown, - enter = slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)), + enter = if (overlay) { + slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)) + } else { + fadeIn(tween(150)) + }, modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center), ) { SettingsPanelContent( @@ -300,6 +480,31 @@ fun SettingsSheet( firstFocusRequester = firstFocus, ) } + editingDevice?.let { device -> + DeviceRenameDialog( + device = device, + busy = deviceJob?.isActive == true, + error = devicesError, + onCancel = { editingDevice = null }, + onSave = { name -> + deviceJob?.cancel() + devicesError = null + deviceJob = scope.launch { + try { + withTimeout(8_000L) { ServiceLocator.repository.renameDevice(device, name) } + devices = devices.map { + if (it.deviceId == device.deviceId) it.copy(deviceName = name.trim()) else it + } + editingDevice = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + devicesError = "Couldn’t rename ${device.deviceName}." + } + } + }, + ) + } } } @@ -311,212 +516,639 @@ internal fun SettingsPanelContent( modifier: Modifier = Modifier, firstFocusRequester: FocusRequester? = null, ) { - Column( + Row( modifier = modifier - .width(if (overlay) 640.dp else 900.dp) + .then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth()) .fillMaxHeight() - .background(Panel) + .background(if (overlay) Panel else Canvas) .then( if (overlay) { Modifier.border(1.dp, Color.White.copy(alpha = 0.10f)) } else { Modifier - .padding(vertical = 18.dp) - .clip(RoundedCornerShape(22.dp)) - .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp)) }, - ) - .verticalScroll(rememberScrollState()) - .padding(horizontal = if (overlay) 34.dp else 44.dp, vertical = 30.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + ), ) { - SettingsHeader( - version = state.installedVersion, + SettingsSecondaryRail( + selected = state.selectedPage, + onSelected = actions.onPageSelected, onClose = actions.onClose, + firstFocusRequester = firstFocusRequester, + compact = overlay, + modifier = Modifier + .width(if (overlay) 190.dp else 224.dp) + .fillMaxHeight(), ) - - SettingsSection( - title = "Appearance", - description = "Artwork and playback accents", - icon = Icons.Default.Palette, + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding( + start = if (overlay) 26.dp else 42.dp, + end = if (overlay) 28.dp else 64.dp, + top = if (overlay) 28.dp else 38.dp, + bottom = 38.dp, + ), + verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp), ) { - SettingsToggleRow( - title = "Title artwork", - description = "Use each film or show logo when artwork is available.", - checked = state.showLogo, - onCheckedChange = actions.onShowLogoChanged, - modifier = firstFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier, + SettingsHeader( + page = state.selectedPage, + version = state.installedVersion, ) - SettingDivider() - SettingsChoiceRow( - title = "Progress ring", - description = "Colour used for resume and countdown progress.", - options = RingOptions, - selected = state.ringColor, - onSelected = actions.onRingColorChanged, - ) - } - - SettingsSection( - title = "Playback", - description = "What happens at the end of an episode", - icon = Icons.Default.PlayArrow, - ) { - SettingsToggleRow( - title = "Play the next episode", - description = "Show a countdown near the end, then continue automatically.", - checked = state.autoPlayNext, - onCheckedChange = actions.onAutoPlayNextChanged, - ) - } - - SettingsSection( - title = "Home screen", - description = "Choose what appears when Memby opens", - icon = Icons.Default.Home, - ) { - listOf( - "continue" to ("Continue watching" to "Resume films and episodes in progress."), - "favorites" to ("Favourites" to "Keep starred films and shows close by."), - "latest" to ("Latest movies" to "Show recently added films."), - ).forEachIndexed { index, (key, copy) -> - if (index > 0) SettingDivider() - SettingsToggleRow( - title = copy.first, - description = copy.second, - checked = key in state.homeSections, - onCheckedChange = { actions.onHomeSectionChanged(key, it) }, - ) - } - SettingDivider() - SettingsChoiceRow( - title = "Card size", - description = "How much content fits across each row.", - options = DensityOptions, - selected = state.cardDensity, - onSelected = actions.onCardDensityChanged, - ) - SettingDivider() - SettingsToggleRow( - title = "Card details", - description = "Show episode, runtime and resume information below artwork.", - checked = state.showCardMetadata, - onCheckedChange = actions.onShowCardMetadataChanged, - ) - } - - SettingsSection( - title = "Updates", - description = "Keep this TV on the current Memby release", - icon = Icons.Default.SystemUpdate, - ) { - if (state.editableServer) { - SettingsTextField( - label = "Update source", - hint = "A latest.json URL or Gitea host", - value = state.baseUrl, - onValueChange = actions.onBaseUrlChanged, - keyboardType = KeyboardType.Uri, - ) - Spacer(Modifier.height(10.dp)) - SettingsTextField( - label = "Repository", - hint = "owner/repository — Gitea only", - value = state.repoPath, - onValueChange = actions.onRepoPathChanged, - ) - Spacer(Modifier.height(10.dp)) - SettingsTextField( - label = "Access token", - hint = "Optional", - value = state.token, - onValueChange = actions.onTokenChanged, - isPassword = true, - ) - Spacer(Modifier.height(12.dp)) - } else { - Text( - "Update source can be edited from the launcher.", - color = TextSecondary, - fontSize = 14.sp, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - ) - SettingDivider() - } - - SettingsActionRow( - title = if (state.checking) "Checking for updates…" else "Check for updates", - description = "Installed version ${state.installedVersion}", - badge = if (state.checking) "WORKING" else "CHECK NOW", - onClick = actions.onCheckForUpdates, - ) - - when (val update = state.updateStatus) { - is UpdateStatus.UpToDate -> SettingsNotice( - text = "You’re up to date · ${update.version}", - positive = true, - ) - is UpdateStatus.Error -> SettingsNotice(update.message, positive = false) - is UpdateStatus.Available -> { - SettingsNotice("Version ${update.version} is ready", positive = true) - if (update.notes.isNotBlank()) { - Text( - update.notes, - color = TextSecondary, - fontSize = 13.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp), + when (state.selectedPage) { + SettingsPage.APPEARANCE -> SettingsSection( + title = "Appearance", + description = "Artwork and playback accents", + icon = Icons.Default.Palette, + ) { + SettingsToggleRow( + title = "Title artwork", + description = "Use each film or show logo when artwork is available.", + checked = state.showLogo, + onCheckedChange = actions.onShowLogoChanged, + ) + SettingDivider() + SettingsChoiceRow( + title = "Progress ring", + description = "Colour used for resume and countdown progress.", + options = RingOptions, + selected = state.ringColor, + onSelected = actions.onRingColorChanged, + ) + } + SettingsPage.PLAYBACK -> SettingsSection( + title = "Playback", + description = "Reminders and episode behaviour", + icon = Icons.Default.PlayArrow, + ) { + SettingsToggleRow( + title = "10-minute reminder", + description = "Show a brief reminder when a film or episode has ten minutes left.", + checked = state.showTenMinuteReminder, + onCheckedChange = actions.onShowTenMinuteReminderChanged, + ) + SettingDivider() + SettingsToggleRow( + title = "Play the next episode", + description = "Show a countdown near the end, then continue automatically.", + checked = state.autoPlayNext, + onCheckedChange = actions.onAutoPlayNextChanged, + ) + } + SettingsPage.HOME -> SettingsSection( + title = "Home screen", + description = "Choose what appears when Memby opens", + icon = Icons.Default.Home, + ) { + listOf( + "continue" to ("Continue watching" to "Resume films and episodes in progress."), + "favorites" to ("Favourites" to "Keep starred films and shows close by."), + "latest" to ("Latest movies" to "Show recently added films."), + ).forEachIndexed { index, (key, copy) -> + if (index > 0) SettingDivider() + SettingsToggleRow( + title = copy.first, + description = copy.second, + checked = key in state.homeSections, + onCheckedChange = { actions.onHomeSectionChanged(key, it) }, ) } + SettingDivider() + SettingsToggleRow( + title = "Hide watched movies", + description = "Remove completed films from browse rows and the Home hero.", + checked = state.hideWatchedMovies, + onCheckedChange = actions.onHideWatchedMoviesChanged, + ) + SettingDivider() + SettingsChoiceRow( + title = "Card size", + description = "How much content fits across each row.", + options = DensityOptions, + selected = state.cardDensity, + onSelected = actions.onCardDensityChanged, + ) + SettingDivider() + SettingsChoiceRow( + title = "Artwork style", + description = "Prefer portrait posters, wide backdrops, or let Memby choose.", + options = ArtworkOptions, + selected = state.artworkStyle, + onSelected = actions.onArtworkStyleChanged, + ) + SettingDivider() + SettingsToggleRow( + title = "Card details", + description = "Show episode, runtime and resume information below artwork.", + checked = state.showCardMetadata, + onCheckedChange = actions.onShowCardMetadataChanged, + ) + if (state.hiddenHomeRowCount > 0) { + SettingDivider() + SettingsActionRow( + title = "Restore hidden rows", + description = "Make all rows available on Home again.", + badge = "${state.hiddenHomeRowCount} HIDDEN", + onClick = actions.onRestoreHiddenRows, + ) + } + } + SettingsPage.WELCOME -> SettingsSection( + title = "Welcome messages", + description = "Choose Memby’s mood when you arrive", + icon = Icons.Default.TagFaces, + ) { + SettingsChoiceRow( + title = "Quote style", + description = "A different line is picked after sign-in and while Memby loads.", + options = WelcomeOptions, + selected = state.welcomeQuoteStyle, + onSelected = actions.onWelcomeQuoteStyleChanged, + ) + SettingsNotice( + text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) { + WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”" + WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”" + WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”" + }, + positive = true, + ) + } + SettingsPage.UPDATES -> SettingsSection( + title = "Updates", + description = "Keep this TV on the current Memby release", + icon = Icons.Default.SystemUpdate, + ) { + VersionRow("Current version", state.installedVersion) + SettingDivider() + VersionRow( + "New version", + when (val update = state.updateStatus) { + is UpdateStatus.Available -> update.version + is UpdateStatus.UpToDate -> update.version + is UpdateStatus.Error -> "Unavailable" + null -> "Not checked" + }, + ) + SettingDivider() SettingsActionRow( - title = "Download and install", - description = "Android will ask for confirmation.", - badge = "INSTALL", - onClick = { actions.onInstallUpdate(update) }, + title = if (state.checking) "Checking for updates…" else "Check for updates", + description = "Ask the configured Memby release server.", + badge = if (state.checking) "WORKING" else "CHECK NOW", + onClick = actions.onCheckForUpdates, + ) + when (val update = state.updateStatus) { + is UpdateStatus.UpToDate -> SettingsNotice("Memby is up to date.", positive = true) + is UpdateStatus.Error -> SettingsNotice(update.message, positive = false) + is UpdateStatus.Available -> { + SettingsNotice("Version ${update.version} is ready.", positive = true) + if (update.notes.isNotBlank()) { + Text( + update.notes, + color = TextSecondary, + fontSize = 13.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + SettingsActionRow( + title = "Download and install", + description = "Android will ask for confirmation.", + badge = "INSTALL", + onClick = { actions.onInstallUpdate(update) }, + ) + } + null -> Unit + } + state.installMessage?.let { SettingsNotice(it, positive = true) } + } + SettingsPage.DEVICES -> SettingsSection( + title = "Signed-in devices", + description = "Review and remove TVs connected to this account", + icon = Icons.Default.Devices, + ) { + when { + state.devicesLoading && state.devices.isEmpty() -> + SettingsNotice("Loading signed-in devices…", positive = true) + state.devices.isEmpty() -> + SettingsNotice("No signed-in devices were found.", positive = false) + else -> state.devices.forEachIndexed { index, device -> + if (index > 0) SettingDivider() + DeviceManagementRow( + device = device, + pendingRemoval = state.pendingRemovalDeviceId == device.deviceId, + removing = state.removingDeviceId == device.deviceId, + onRename = { actions.onRenameDevice(device) }, + onRemove = { actions.onRemoveDevice(device) }, + onCancelRemove = actions.onCancelDeviceRemoval, + ) + } + } + state.devicesError?.let { SettingsNotice(it, positive = false) } + SettingsActionRow( + title = if (state.devicesLoading) "Refreshing devices…" else "Refresh devices", + description = "Check the gateway for TVs that are currently signed in.", + badge = "REFRESH", + onClick = actions.onRefreshDevices, + ) + } + SettingsPage.ABOUT -> SettingsSection( + title = "About", + description = "Memby for Android TV", + icon = Icons.Default.Info, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.app_name), + color = TextPrimary, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + Text( + "Made by ${stringResource(R.string.developer_name)}", + color = TextSecondary, + fontSize = 13.sp, + ) + } + Text( + "v${state.installedVersion}", + color = EmbyGreen, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + } + SettingDivider() + SettingsActionRow( + title = "Source code", + description = BuildConfig.SOURCE_CODE_URL, + badge = "OPEN", + onClick = actions.onOpenSourceCode, + ) + SettingDivider() + SettingsActionRow( + title = "Open-source licences", + description = "Memby's GPLv2 terms and third-party acknowledgements.", + badge = "VIEW", + onClick = actions.onOpenLicenses, + ) + } + SettingsPage.LICENSES -> SettingsSection( + title = "Open-source licences", + description = "Available offline with every Memby installation", + icon = Icons.Default.Description, + ) { + LegalTextBlock( + title = "Memby and acknowledgements", + body = BuildConfig.PROJECT_NOTICE_TEXT, + ) + SettingDivider() + LegalTextBlock( + title = "GNU General Public License v2", + body = BuildConfig.GPL_LICENSE_TEXT, + monospace = true, ) } - null -> Unit } - state.installMessage?.let { SettingsNotice(it, positive = true) } + Spacer(Modifier.height(12.dp)) } + } +} - SettingsSection( - title = "About", - description = "Memby for Android TV", - icon = Icons.Default.Info, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { +internal fun deviceDescription(device: GatewayDevice): String = buildList { + if (device.current) add("This TV") + device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") } + device.lastSeenAt.takeIf(String::isNotBlank)?.let { + add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}") + } +}.joinToString(" • ").ifBlank { "Signed in" } + +@Composable +private fun DeviceManagementRow( + device: GatewayDevice, + pendingRemoval: Boolean, + removing: Boolean, + onRename: () -> Unit, + onRemove: () -> Unit, + onCancelRemove: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + device.deviceName.ifBlank { "Memby TV" }, + color = TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + if (device.current) { Text( - stringResource(R.string.app_name), - color = TextPrimary, - fontSize = 17.sp, + "CURRENT TV", + color = EmbyGreen, + fontSize = 9.sp, fontWeight = FontWeight.Bold, - ) - Text( - "Made by ${stringResource(R.string.developer_name)}", - color = TextSecondary, - fontSize = 13.sp, + letterSpacing = 0.6.sp, ) } - Text( - "v${state.installedVersion}", - color = EmbyGreen, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - ) } + Text(deviceDescription(device), color = TextSecondary, fontSize = 12.sp, maxLines = 2) + } + if (pendingRemoval) { + DeviceActionChip("Cancel", onCancelRemove) + DeviceActionChip(if (removing) "Removing…" else "Confirm remove", onRemove, enabled = !removing) + } else { + DeviceActionChip("Rename", onRename) + if (!device.current) DeviceActionChip("Remove", onRemove, destructive = true) } - Spacer(Modifier.height(12.dp)) } } @Composable -private fun SettingsHeader(version: String, onClose: () -> Unit) { +private fun DeviceActionChip( + label: String, + onClick: () -> Unit, + enabled: Boolean = true, + destructive: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val accent = if (destructive) Color(0xFFE86D68) else EmbyGreen + Text( + text = label, + color = when { + !enabled -> TextQuiet + focused -> Canvas + else -> accent + }, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (focused) accent else accent.copy(alpha = if (enabled) 0.13f else 0.05f)) + .border(1.dp, if (focused) Color.White else accent.copy(alpha = 0.28f), RoundedCornerShape(8.dp)) + .onFocusChanged { focused = it.isFocused } + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) +} + +@Composable +private fun DeviceRenameDialog( + device: GatewayDevice, + busy: Boolean, + error: String?, + onCancel: () -> Unit, + onSave: (String) -> Unit, +) { + var name by rememberSaveable(device.deviceId) { mutableStateOf(device.deviceName) } + var fieldFocused by remember { mutableStateOf(false) } + val fieldFocus = remember { FocusRequester() } + val cancelFocus = remember { FocusRequester() } + val saveFocus = remember { FocusRequester() } + val valid = name.trim().isNotEmpty() && name.length <= 80 + BackHandler(enabled = !busy, onBack = onCancel) + LaunchedEffect(device.deviceId) { + delay(80L) + runCatching { fieldFocus.requestFocus() } + } + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(520.dp) + .focusGroup() + .clip(RoundedCornerShape(18.dp)) + .background(Panel) + .border(1.dp, Hairline, RoundedCornerShape(18.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Rename device", color = TextPrimary, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Text("Choose a name that makes this TV easy to recognise.", color = TextSecondary, fontSize = 14.sp) + BasicTextField( + value = name, + onValueChange = { if (it.length <= 80) name = it }, + singleLine = true, + enabled = !busy, + textStyle = TextStyle(color = TextPrimary, fontSize = 18.sp), + cursorBrush = SolidColor(EmbyGreen), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (valid && !busy) onSave(name) }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(fieldFocus) + .focusProperties { down = saveFocus } + .onFocusChanged { fieldFocused = it.isFocused } + .clip(RoundedCornerShape(10.dp)) + .background(ControlIdle) + .border( + if (fieldFocused) 2.dp else 1.dp, + if (fieldFocused) EmbyGreen else Hairline, + RoundedCornerShape(10.dp), + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + error?.let { Text(it, color = Color(0xFFFF9B98), fontSize = 13.sp) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + ) { + Button( + onClick = onCancel, + enabled = !busy, + modifier = Modifier.focusRequester(cancelFocus).focusProperties { right = saveFocus }, + ) { Text("Cancel") } + Button( + onClick = { onSave(name) }, + enabled = valid && !busy, + modifier = Modifier.focusRequester(saveFocus).focusProperties { left = cancelFocus }, + ) { Text(if (busy) "Saving…" else "Save") } + } + } + } +} + +@Composable +private fun SettingsSecondaryRail( + selected: SettingsPage, + onSelected: (SettingsPage) -> Unit, + onClose: () -> Unit, + firstFocusRequester: FocusRequester?, + compact: Boolean, + modifier: Modifier = Modifier, +) { + val pages = SettingsPage.entries.filter { it.showInRail } + val selectedRailPage = selected.takeIf { it.showInRail } ?: SettingsPage.ABOUT + // The home screen remains composed behind this panel. Relying on spatial focus + // search therefore lets a covered media card beat the next rail item when their + // bounds happen to be closer (notably below Playback on a 540p viewport). Give + // every vertical move an explicit destination so focus cannot leak through the + // settings surface and activate home content. + val railFocusRequesters = remember(firstFocusRequester) { + buildList { + add(FocusRequester()) // Exit + pages.forEach { page -> + add( + if (page == selectedRailPage && firstFocusRequester != null) { + firstFocusRequester + } else { + FocusRequester() + }, + ) + } + } + } + Column( + modifier = modifier + .background(Color(0xFF0A0E11)) + .border(width = 1.dp, color = Hairline) + .padding( + horizontal = if (compact) 14.dp else 18.dp, + vertical = if (compact) 28.dp else 38.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "SETTINGS", + color = TextQuiet, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(start = 12.dp, bottom = 8.dp), + ) + SettingsExitRailItem( + compact = compact, + onClick = onClose, + focusRequester = railFocusRequesters[0], + downFocusRequester = railFocusRequesters[1], + ) + pages.forEachIndexed { index, page -> + var focused by remember { mutableStateOf(false) } + val active = page == selectedRailPage + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + when { + focused -> Color.White + active -> EmbyGreen.copy(alpha = 0.16f) + else -> Color.Transparent + }, + ) + .border( + width = 1.dp, + color = when { + focused -> Color.White + active -> EmbyGreen.copy(alpha = 0.40f) + else -> Color.Transparent + }, + shape = RoundedCornerShape(10.dp), + ) + .focusRequester(railFocusRequesters[index + 1]) + .focusProperties { + up = railFocusRequesters[index] + down = if (index == pages.lastIndex) { + FocusRequester.Cancel + } else { + railFocusRequesters[index + 2] + } + left = FocusRequester.Cancel + } + .onFocusChanged { focused = it.isFocused } + .clickable { onSelected(page) } + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + page.icon, + contentDescription = null, + tint = when { + focused -> Canvas + active -> EmbyGreen + else -> TextSecondary + }, + modifier = Modifier.size(19.dp), + ) + Text( + page.label, + color = if (focused) Canvas else TextPrimary, + fontSize = if (compact) 13.sp else 14.sp, + fontWeight = if (active || focused) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + ) + } + } + Spacer(Modifier.weight(1f)) + Text( + "Use ↑ ↓ to move\nbetween pages", + color = TextQuiet, + fontSize = 11.sp, + lineHeight = 16.sp, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } +} + +@Composable +private fun SettingsExitRailItem( + compact: Boolean, + onClick: () -> Unit, + focusRequester: FocusRequester, + downFocusRequester: FocusRequester, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) Color.White else Color.Transparent) + .border( + width = 1.dp, + color = if (focused) Color.White else Color.Transparent, + shape = RoundedCornerShape(10.dp), + ) + .focusRequester(focusRequester) + .focusProperties { + up = FocusRequester.Cancel + down = downFocusRequester + left = FocusRequester.Cancel + } + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = if (focused) Canvas else TextSecondary, + modifier = Modifier.size(19.dp), + ) + Text( + "Exit", + color = if (focused) Canvas else TextPrimary, + fontSize = if (compact) 13.sp else 14.sp, + fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + ) + } +} + +@Composable +private fun SettingsHeader(page: SettingsPage, version: String) { Row(verticalAlignment = Alignment.CenterVertically) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( @@ -526,43 +1158,30 @@ private fun SettingsHeader(version: String, onClose: () -> Unit) { fontWeight = FontWeight.Bold, letterSpacing = 1.8.sp, ) - Text("Settings", color = TextPrimary, fontSize = 32.sp, fontWeight = FontWeight.Bold) + Text(page.label, color = TextPrimary, fontSize = 38.sp, fontWeight = FontWeight.Bold) Text( - "Make Memby feel right for this screen.", + page.description, color = TextSecondary, - fontSize = 14.sp, + fontSize = 15.sp, ) } Spacer(Modifier.weight(1f)) - Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("v$version", color = TextQuiet, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) - SettingsIconButton(onClick = onClose) - } + Text("v$version", color = TextQuiet, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) } } @Composable -private fun SettingsIconButton(onClick: () -> Unit) { - var focused by remember { mutableStateOf(false) } - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(if (focused) Color.White else ControlIdle) - .border( - width = if (focused) 2.dp else 1.dp, - color = if (focused) Color.White else Hairline, - shape = CircleShape, - ) - .onFocusChanged { focused = it.isFocused } - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, +private fun VersionRow(label: String, version: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Default.Close, - contentDescription = "Close settings", - tint = if (focused) Canvas else TextPrimary, - modifier = Modifier.size(20.dp), + Text(label, color = TextSecondary, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Text( + version, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, ) } } @@ -599,8 +1218,8 @@ private fun SettingsSection( .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .background(SectionSurface) - .border(1.dp, Hairline, RoundedCornerShape(14.dp)) - .padding(6.dp), + .border(1.dp, Color.White.copy(alpha = 0.065f), RoundedCornerShape(14.dp)) + .padding(7.dp), content = content, ) } @@ -791,57 +1410,6 @@ private fun SettingsActionRow( } } -@Composable -private fun SettingsTextField( - label: String, - hint: String, - value: String, - onValueChange: (String) -> Unit, - isPassword: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text, -) { - var focused by remember { mutableStateOf(false) } - Column( - modifier = Modifier.padding(horizontal = 10.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text(label.uppercase(), color = TextQuiet, fontSize = 10.sp, fontWeight = FontWeight.Bold) - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(9.dp)) - .background(Color(0xFF0D1114)) - .border( - width = if (focused) 2.dp else 1.dp, - color = if (focused) EmbyGreen else Color.White.copy(alpha = 0.12f), - shape = RoundedCornerShape(9.dp), - ) - .onFocusChanged { focused = it.hasFocus } - .padding(horizontal = 14.dp, vertical = 11.dp), - ) { - if (value.isEmpty()) { - Text(hint, color = TextQuiet, fontSize = 14.sp) - } - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp), - cursorBrush = SolidColor(EmbyGreen), - visualTransformation = if (isPassword) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - }, - keyboardOptions = KeyboardOptions( - keyboardType = if (isPassword) KeyboardType.Password else keyboardType, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - @Composable private fun SettingsNotice(text: String, positive: Boolean) { Text( @@ -859,6 +1427,32 @@ private fun SettingsNotice(text: String, positive: Boolean) { ) } +@Composable +private fun LegalTextBlock( + title: String, + body: String, + monospace: Boolean = false, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + title, + color = TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + Text( + body, + color = TextSecondary, + fontSize = if (monospace) 11.sp else 13.sp, + lineHeight = if (monospace) 16.sp else 18.sp, + fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default, + ) + } +} + @Composable private fun SettingDivider() { Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(1.dp).background(Hairline)) @@ -876,8 +1470,7 @@ private fun SettingsPanelPreview() { homeSections = setOf("continue", "favorites"), cardDensity = "standard", showCardMetadata = false, - editableServer = true, - baseUrl = "https://mserver.example.com/releases/latest.json", + welcomeQuoteStyle = "positive", installedVersion = "0.1.60", ), actions = SettingsPanelActions(), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt new file mode 100644 index 0000000..699addd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt @@ -0,0 +1,63 @@ +package com.ponzischeme89.memby.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * The one vocabulary of colour, shape and punctuation shared by the launcher and the detail + * pages. + * + * These two surfaces sit next to each other the moment a detail page opens from a home row, + * and they had drifted into four near-blacks, four greens, two secondary-text greys and + * eight corner radii. Nothing here is new design — it is the values that were already + * winning, named once so a change lands on both screens at the same time. + */ + +/** The near-black every full-screen surface is drawn on. */ +val MembySurface = Color(0xFF090B0D) + +/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */ +val MembySurfaceRaised = Color(0xFF101418) + +/** Emby's green. The only accent in the app. */ +val MembyAccent = Color(0xFF52B54B) + +/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */ +val MembyOnSurface = Color(0xFFE2E5E8) + +/** + * Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as + * secondary without falling into low-contrast grey-on-black. + */ +val MembyMutedText = Color(0xFFD0D6DB) +val MembyQuietText = Color(0xFFAEB7BF) + +/** Hairline rules and unfocused borders. */ +val MembyHairline = Color(0x28FFFFFF) + +/** The community score, wherever it is rendered as its own run of text. */ +val MembyScore = Color(0xFFF5C518) + +// --- Shape --------------------------------------------------------------------------- +// Three steps, largest last. Anything that needs a radius picks the nearest one rather +// than inventing a fourth. + +/** Chips, badges and small controls. */ +val MembyChipCorner = 8.dp + +/** Cards: posters, episode rows, buttons. */ +val MembyCardCorner = 10.dp + +/** Full panels: heroes, overlays, confirmation toasts. */ +val MembyPanelCorner = 14.dp + +// --- Punctuation --------------------------------------------------------------------- + +/** + * Between one fact and the next — year, runtime, certificate. One separator everywhere, + * because the same line is drawn by the home hero, the metadata panel and the detail page. + */ +const val FactSeparator = " • " + +/** Within a single fact that happens to hold a list: genres, codecs, audio attributes. */ +const val ValueSeparator = " · " diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt index e78c466..8d8de2c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt @@ -9,11 +9,14 @@ import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme +// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so +// a component that falls back to a theme colour lands on the surface it is sitting on +// rather than one shade beside it. private val EmbyColors = darkColorScheme( - primary = androidx.compose.ui.graphics.Color(0xFF52B54B), + primary = MembyAccent, onPrimary = androidx.compose.ui.graphics.Color.White, - surface = androidx.compose.ui.graphics.Color(0xFF101418), - background = androidx.compose.ui.graphics.Color(0xFF0B0E11), + surface = MembySurfaceRaised, + background = MembySurface, ) @Composable diff --git a/app/src/main/java/com/ponzischeme89/memby/update/ServerUpdateService.kt b/app/src/main/java/com/ponzischeme89/memby/update/ServerUpdateService.kt new file mode 100644 index 0000000..6bbaa3f --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/ServerUpdateService.kt @@ -0,0 +1,37 @@ +package com.ponzischeme89.memby.update + +import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.data.remote.GatewayServiceFactory +import kotlinx.coroutines.CancellationException + +/** + * App-level update client, deliberately independent of profiles and saved sessions. + * + * The gateway endpoint is public and the Retrofit client below never receives a bearer + * token. This makes an update check safe before sign-in and prevents update failures from + * invalidating, replacing, or otherwise coupling themselves to a viewer's credentials. + */ +class ServerUpdateService private constructor( + private val checkRemote: (suspend () -> GatewayUpdate)?, +) { + suspend fun check(): Result { + val remote = checkRemote ?: return Result.success(null) + return try { + Result.success(remote().takeIf { it.isActionable }) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + Result.failure(failure) + } + } + + companion object { + fun create(gatewayUrl: String?): ServerUpdateService { + val api = gatewayUrl?.let { GatewayServiceFactory.create(it) { null } } + return ServerUpdateService(api?.let { { it.updateStatus() } }) + } + + internal fun forTest(check: suspend () -> GatewayUpdate): ServerUpdateService = + ServerUpdateService(check) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt index 3662169..73c8d16 100644 --- a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt @@ -2,9 +2,12 @@ package com.ponzischeme89.memby.update import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider +import androidx.core.content.pm.PackageInfoCompat import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -12,6 +15,8 @@ import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest import java.util.concurrent.TimeUnit /** Result of a "check for updates" against the configured Gitea release. */ @@ -156,7 +161,13 @@ class UpdateChecker(private val context: Context) { * needs the "install unknown apps" permission; if it's missing we send the user * to that settings screen and return a message asking them to retry. */ - suspend fun downloadAndInstall(apkUrl: String, token: String): Result = + suspend fun downloadAndInstall( + apkUrl: String, + token: String, + expectedVersion: String = "", + expectedSHA256: String = "", + expectedSizeBytes: Long = 0, + ): Result = withContext(Dispatchers.IO) { // Gate on the install-unknown-apps permission before spending a download. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && @@ -170,21 +181,75 @@ class UpdateChecker(private val context: Context) { context.startActivity(intent) } return@withContext Result.failure( - IllegalStateException("Allow Memby to install apps, then check again.") + InstallPermissionRequiredException( + "Allow Memby to install apps. The update will continue when you return.", + ), ) } runCatching { - val file = File(context.cacheDir, "memby-update.apk") + val updateDir = File(context.cacheDir, "updates").apply { mkdirs() } + val partial = File(updateDir, "memby-update.part") + val file = File(updateDir, "memby-update.apk") + partial.delete() val req = Request.Builder().url(apkUrl).apply { if (token.isNotBlank()) header("Authorization", "token ${token.trim()}") }.build() http.newCall(req).execute().use { resp -> if (!resp.isSuccessful) error("Download failed (${resp.code}).") val body = resp.body ?: error("Empty download.") - file.outputStream().use { out -> body.byteStream().copyTo(out) } + val declaredSize = body.contentLength() + if (declaredSize > MAX_APK_BYTES) error("The update is unexpectedly large.") + if (expectedSizeBytes > 0 && declaredSize >= 0 && + declaredSize != expectedSizeBytes + ) { + error("The update size does not match the published release.") + } + + val digest = MessageDigest.getInstance("SHA-256") + var written = 0L + try { + FileOutputStream(partial).use { out -> + body.byteStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > MAX_APK_BYTES) { + error("The update is unexpectedly large.") + } + digest.update(buffer, 0, read) + out.write(buffer, 0, read) + } + } + out.fd.sync() + } + } catch (error: Throwable) { + partial.delete() + throw error + } + if (written == 0L || (expectedSizeBytes > 0 && written != expectedSizeBytes)) { + partial.delete() + error("The update download was incomplete.") + } + val actualSHA256 = digest.digest().joinToString("") { + "%02x".format(it.toInt() and 0xff) + } + if (expectedSHA256.isNotBlank() && + !actualSHA256.equals(expectedSHA256.trim(), ignoreCase = true) + ) { + partial.delete() + error("The update failed its integrity check.") + } } + verifyApk(partial, expectedVersion) + file.delete() + if (!partial.renameTo(file)) { + partial.delete() + error("The verified update could not be prepared.") + } val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file, ) @@ -196,6 +261,51 @@ class UpdateChecker(private val context: Context) { } } + @Suppress("DEPRECATION") + private fun verifyApk(file: File, expectedVersion: String) { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags) + ?: error("The downloaded file is not a valid Android app.") + if (archive.packageName != context.packageName) { + error("The update belongs to a different app.") + } + if (expectedVersion.isNotBlank() && + normalizeVersion(archive.versionName.orEmpty()) != normalizeVersion(expectedVersion) + ) { + error("The downloaded app version does not match the published release.") + } + + val installed = context.packageManager.getPackageInfo(context.packageName, flags) + if (PackageInfoCompat.getLongVersionCode(archive) <= + PackageInfoCompat.getLongVersionCode(installed) + ) { + error("Android requires an update with a higher version code.") + } + if (signerDigests(archive) != signerDigests(installed) || + signerDigests(archive).isEmpty() + ) { + error("The update was not signed by Memby’s trusted release key.") + } + } + + @Suppress("DEPRECATION") + private fun signerDigests(info: PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.signingInfo?.apkContentsSigners.orEmpty() + } else { + info.signatures.orEmpty() + } + return signatures.mapTo(linkedSetOf()) { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + } + /** True when [remote] parses to a strictly higher version than [installed]. */ private fun isNewer(remote: String, installed: String): Boolean { val r = parseVersion(remote) @@ -212,8 +322,14 @@ class UpdateChecker(private val context: Context) { normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() } private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V') + + companion object { + private const val MAX_APK_BYTES = 250L * 1024L * 1024L + } } +class InstallPermissionRequiredException(message: String) : IllegalStateException(message) + /** * A `.json` update URL means "static manifest"; anything else is treated as a Gitea host. * Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt index d31128c..525bfd9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt @@ -3,20 +3,19 @@ package com.ponzischeme89.memby.update import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity +import com.ponzischeme89.memby.ui.MainActivity /** - * Reopens the launcher entry point when this package is replaced in place. + * Reopens the normal app entry point when this package is replaced in place. * - * Replacing an APK kills its process, including an active Dream's render process. On - * TV that can leave the old Dream surface black. Android sends this broadcast to the - * newly installed package, giving us a chance to present a real UI instead. + * The normal entry point runs the public update check and then reuses the existing saved + * session. Nothing in this recovery path clears app data, profiles, or credentials. */ class UpdateRecoveryReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return - val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply { + val launch = Intent(context, MainActivity::class.java).apply { addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or diff --git a/app/src/main/res/drawable/player_osd_bottom_gradient.xml b/app/src/main/res/drawable/player_osd_bottom_gradient.xml index fece60d..496a7df 100644 --- a/app/src/main/res/drawable/player_osd_bottom_gradient.xml +++ b/app/src/main/res/drawable/player_osd_bottom_gradient.xml @@ -2,7 +2,8 @@ diff --git a/app/src/main/res/drawable/player_osd_left_gradient.xml b/app/src/main/res/drawable/player_osd_left_gradient.xml new file mode 100644 index 0000000..19c5fe0 --- /dev/null +++ b/app/src/main/res/drawable/player_osd_left_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/player_pause_poster_background.xml b/app/src/main/res/drawable/player_pause_poster_background.xml new file mode 100644 index 0000000..3ab995f --- /dev/null +++ b/app/src/main/res/drawable/player_pause_poster_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/player_pause_scrim.xml b/app/src/main/res/drawable/player_pause_scrim.xml new file mode 100644 index 0000000..0b06f82 --- /dev/null +++ b/app/src/main/res/drawable/player_pause_scrim.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/player_preroll_card_background.xml b/app/src/main/res/drawable/player_preroll_card_background.xml new file mode 100644 index 0000000..e702831 --- /dev/null +++ b/app/src/main/res/drawable/player_preroll_card_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/player_preroll_card_frame.xml b/app/src/main/res/drawable/player_preroll_card_frame.xml new file mode 100644 index 0000000..6c82ebe --- /dev/null +++ b/app/src/main/res/drawable/player_preroll_card_frame.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/player_preroll_card_scrim.xml b/app/src/main/res/drawable/player_preroll_card_scrim.xml new file mode 100644 index 0000000..64c7923 --- /dev/null +++ b/app/src/main/res/drawable/player_preroll_card_scrim.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/player_preroll_video_background.xml b/app/src/main/res/drawable/player_preroll_video_background.xml index 5fb52a5..bf4db40 100644 --- a/app/src/main/res/drawable/player_preroll_video_background.xml +++ b/app/src/main/res/drawable/player_preroll_video_background.xml @@ -2,6 +2,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/time_remaining_cue_background.xml b/app/src/main/res/drawable/time_remaining_cue_background.xml new file mode 100644 index 0000000..640c84e --- /dev/null +++ b/app/src/main/res/drawable/time_remaining_cue_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 667505b..9b8cd8a 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -18,6 +18,18 @@ app:surface_type="surface_view" app:use_controller="true" /> + + + + + + + + + @@ -29,6 +41,17 @@ + + + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_playback_identity.xml b/app/src/main/res/layout/player_playback_identity.xml new file mode 100644 index 0000000..5318c12 --- /dev/null +++ b/app/src/main/res/layout/player_playback_identity.xml @@ -0,0 +1,39 @@ + + + + + + + + diff --git a/app/src/main/res/layout/player_preroll.xml b/app/src/main/res/layout/player_preroll.xml index 4d6e36a..a983b83 100644 --- a/app/src/main/res/layout/player_preroll.xml +++ b/app/src/main/res/layout/player_preroll.xml @@ -5,114 +5,152 @@ android:layout_height="match_parent" android:background="#FF050708" android:clickable="true" + android:clipChildren="false" android:focusable="false" android:visibility="gone"> + + - - - - - - - - + android:clipChildren="false" + android:gravity="center_horizontal" + android:orientation="vertical" + android:paddingStart="44dp" + android:paddingTop="84dp" + android:paddingEnd="44dp" + android:paddingBottom="22dp"> + android:layout_width="match_parent" + android:layout_height="224dp" + android:gravity="center_vertical" + android:orientation="horizontal"> - + - + - + + + + + + android:layout_weight="1" + android:orientation="vertical"> - + - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_preroll_schedule_card.xml b/app/src/main/res/layout/player_preroll_schedule_card.xml new file mode 100644 index 0000000..959cbc6 --- /dev/null +++ b/app/src/main/res/layout/player_preroll_schedule_card.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_season_finale.xml b/app/src/main/res/layout/player_season_finale.xml new file mode 100644 index 0000000..1d88c91 --- /dev/null +++ b/app/src/main/res/layout/player_season_finale.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_time_remaining.xml b/app/src/main/res/layout/player_time_remaining.xml new file mode 100644 index 0000000..f3df3fb --- /dev/null +++ b/app/src/main/res/layout/player_time_remaining.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc6bdeb..677ccf4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,10 @@ Reconnecting in %1$d seconds… NOW PLAYING + PAUSED + Movie poster + Press OK to resume + No description is available for this title. / Loading duration… Live @@ -29,12 +33,24 @@ TEXT SIZE BACK · CLOSE Ends at %1$s - Starting in 5 seconds… + Starting in 7 seconds… + Starting in + Memby logo Starting in %1$d second… Starting in %1$d seconds… Starting now… + TIME REMAINING + SEASON FINALE + %1$s · Season %2$d finale + FINISHES IN + TIME LEFT + %1$s (%2$s) + + %1$d min + %1$d mins + NEXT UP Play now Dismiss diff --git a/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt new file mode 100644 index 0000000..c3d0c55 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt @@ -0,0 +1,48 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities +import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens +import org.junit.Assert.assertTrue +import org.junit.Test + +class DevicePlaybackCapabilitiesTest { + @Test + fun detailedH264AndHevcDecoderSupportIsReportedToTheGateway() { + val capabilities = DevicePlaybackCapabilities( + h264 = VideoDecoderCapabilities( + supported = true, + profiles = setOf("baseline", "main", "high"), + mainLevel = 52, + maxWidth = 3840, + maxHeight = 2160, + ), + hevc = VideoDecoderCapabilities( + supported = true, + profiles = setOf("main", "main10"), + mainLevel = 153, + tenBitLevel = 153, + maxWidth = 3840, + maxHeight = 2160, + hdr10 = true, + ), + ) + val tokens = capabilities.gatewayCapabilityTokens() + + assertTrue("video_h264_profile_high" in tokens) + assertTrue("video_h264_level_52" in tokens) + assertTrue("video_h264_max_3840x2160" in tokens) + assertTrue("video_hevc_decode" in tokens) + assertTrue("video_hevc_profile_main10" in tokens) + assertTrue("video_hevc_main10_level_153" in tokens) + assertTrue("video_hevc_hdr10" in tokens) + } + + @Test + fun h264OnlyDeviceDoesNotClaimHevc() { + val tokens = DevicePlaybackCapabilities().gatewayCapabilityTokens() + + assertTrue("video_h264_decode" in tokens) + assertTrue("video_hevc_decode" !in tokens) + } +} 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 b9603f2..d404741 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -1,10 +1,14 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.GatewayHome +import com.ponzischeme89.memby.data.model.GatewayDevices import com.ponzischeme89.memby.data.model.GatewayNextEpisode import com.ponzischeme89.memby.data.model.GatewayPlayback +import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse import com.ponzischeme89.memby.data.model.GatewaySearchHistory import com.ponzischeme89.memby.data.model.GatewayServiceStatus +import com.ponzischeme89.memby.data.model.GatewayFeatures +import com.ponzischeme89.memby.data.model.RecommendationOnboarding import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -19,6 +23,17 @@ import org.junit.Test * side, this fails before a TV ever sees it. */ class GatewayPayloadTest { + @Test + fun `decodes signed in devices without an allowance`() { + val response = json.decodeFromString( + """{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""", + ) + + assertEquals(1, response.devices.size) + assertEquals("Living room", response.devices.single().deviceName) + assertTrue(response.devices.single().current) + } + private val json = Json { ignoreUnknownKeys = true @@ -38,6 +53,40 @@ class GatewayPayloadTest { assertEquals(1, status.serverProtocol) } + @Test + fun `decodes live feature policy and recovery state`() { + val status = json.decodeFromString( + """{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""", + ) + val policy = json.decodeFromString( + """{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""", + ) + + assertEquals(7L, status.featureRevision) + assertEquals(false, status.features["sonarr_preroll"]) + assertTrue(policy.safeMode) + assertTrue(policy.canRollback) + assertEquals("safe_mode", policy.features.single().source) + } + + @Test + fun `decodes recommendation onboarding ratings and emby items`() { + val onboarding = json.decodeFromString( + """{ + "completed":false, + "ratings":{"movie-1":5}, + "items":[ + {"Id":"movie-1","Name":"Arrival","Type":"Movie"}, + {"Id":"series-1","Name":"Severance","Type":"Series"} + ] + }""", + ) + + assertEquals(false, onboarding.completed) + assertEquals(5, onboarding.ratings["movie-1"]) + assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name }) + } + @Test fun `decodes a home payload with emby-shaped items`() { val payload = """ @@ -119,12 +168,12 @@ class GatewayPayloadTest { } @Test - fun `decodes the informational Sonarr schedule row`() { + fun `decodes the informational TV schedule row`() { val payload = """ { "rows": [{ "id":"sonarr-airing-today", - "title":"Shows airing today", + "title":"Shows airing in the next 5 days", "kind":"schedule", "items":[{ "Id":"sonarr:7:42", @@ -134,7 +183,8 @@ class GatewayPayloadTest { "MembySource":"sonarr", "MembyEpisodeTitle":"The Crossing", "MembyEpisodeCode":"S02E04", - "MembyAirLabel":"Airs today at 8:00 PM", + "MembyAirDayLabel":"Tomorrow", + "MembyAirLabel":"Tomorrow: 8:00 PM", "MembyAvailability":"downloading", "MembyAvailabilityText":"Downloading", "MembyPlayable":false @@ -150,12 +200,48 @@ class GatewayPayloadTest { val item = json.decodeFromString(payload).rows.single().items.single() - assertTrue(item.isSonarrSchedule) + assertTrue(item.isTvSchedule) assertEquals("S02E04", item.membyEpisodeCode) + assertEquals("Tomorrow", item.membyAirDayLabel) assertEquals("Downloading", item.membyAvailabilityText) assertEquals(false, item.membyPlayable) } + @Test + fun `decodes the informational Radarr digital release row`() { + val payload = """ + { + "rows":[{ + "id":"radarr-upcoming-movies", + "title":"Upcoming Movie releases", + "kind":"movie-schedule", + "items":[{ + "Id":"radarr:7", + "Name":"Arrival", + "Type":"MembyRadarrMovie", + "ImageTags":{"Primary":"radarr"}, + "MembySource":"radarr", + "MembyAirsAt":"2026-08-01T00:00:00+12:00", + "MembyAirDayLabel":"Saturday", + "MembyAirLabel":"Digital release Saturday", + "MembyAvailability":"upcoming", + "MembyAvailabilityText":"Upcoming digital release", + "MembyPlayable":false + }] + }] + } + """.trimIndent() + + val row = json.decodeFromString(payload).rows.single() + val item = row.items.single() + + assertEquals("movie-schedule", row.kind) + assertTrue(item.isMovieSchedule) + assertTrue(item.isSchedule) + assertEquals("Digital release Saturday", item.membyAirLabel) + assertEquals(false, item.membyPlayable) + } + @Test fun `a home payload without rows still decodes`() { // The gateway omits recommendation rows while they are still building, and an @@ -186,13 +272,26 @@ class GatewayPayloadTest { @Test fun `decodes a playback response`() { val playback = json.decodeFromString( - """{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""", + """{"itemId":"9","title":"Severance – Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""", ) assertEquals("9", playback.itemId) assertEquals(42_000L, playback.resumePositionMs) + assertEquals("S01E01", playback.episodeCode) + assertEquals(3_420_000L, playback.runtimeMs) + assertEquals(false, playback.prerollEnabled) + assertEquals(4_000L, playback.prerollDurationMs) assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream")) } + @Test + fun `decodes a one-time auto-follow acknowledgement`() { + val response = json.decodeFromString( + """{"autoFollowedShowTitle":"Severance"}""", + ) + + assertEquals("Severance", response.autoFollowedShowTitle) + } + @Test fun `decodes the next-episode response the player counts down to`() { val next = json.decodeFromString( @@ -235,13 +334,4 @@ class GatewayPayloadTest { assertNull(next.item.episodeCode) } - @Test - fun `device allowance response becomes a specific sign-in error`() { - val error = parseDeviceLimit( - """{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""", - ) - - assertEquals(3, error?.activeClients) - assertEquals(3, error?.maxClients) - } } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt index b6f9b65..75e012d 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt @@ -30,12 +30,14 @@ class GatewayUpdateTest { @Test fun `an optional verdict is dismissable`() { val update = json.decodeFromString( - """{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""", + """{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk","sha256":"abc","sizeBytes":123}""", ) assertTrue(update.isOptional) assertFalse(update.isMandatory) assertTrue(update.isActionable) + assertEquals("abc", update.sha256) + assertEquals(123L, update.sizeBytes) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackDeliveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackDeliveryTest.kt new file mode 100644 index 0000000..a76eba3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackDeliveryTest.kt @@ -0,0 +1,51 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.model.MediaSourceInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlaybackDeliveryTest { + @Test + fun directPlayUsesOriginalOnlyWhenServerMarksItSupported() { + val delivery = selectPlaybackDelivery( + MediaSourceInfo( + supportsDirectPlay = true, + directStreamUrl = "/Videos/item/stream.mkv", + transcodingUrl = "/Videos/item/master.m3u8", + ), + ) + + assertNull(delivery.url) + assertEquals("DirectPlay", delivery.playMethod) + } + + @Test + fun negotiatedDirectStreamIsUsedForContainerCompatibility() { + val delivery = selectPlaybackDelivery( + MediaSourceInfo( + supportsDirectPlay = false, + supportsDirectStream = true, + directStreamUrl = "/Videos/item/stream.mp4", + transcodingUrl = "/Videos/item/master.m3u8", + ), + ) + + assertEquals("/Videos/item/stream.mp4", delivery.url) + assertEquals("DirectStream", delivery.playMethod) + } + + @Test + fun forcedFallbackUsesTranscodeEvenWhenDirectPlayWasAdvertised() { + val delivery = selectPlaybackDelivery( + MediaSourceInfo( + supportsDirectPlay = true, + transcodingUrl = "/Videos/item/master.m3u8", + ), + forceTranscode = true, + ) + + assertEquals("/Videos/item/master.m3u8", delivery.url) + assertEquals("Transcode", delivery.playMethod) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt index 292f054..e075c01 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt @@ -1,7 +1,11 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.DeviceProfile +import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class PlaybackReportMathTest { @@ -26,4 +30,45 @@ class PlaybackReportMathTest { assertEquals("Encode", profiles["pgssub"]) assertEquals("Encode", profiles["dvdsub"]) } + + @Test + fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() { + val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single() + val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single() + + assertEquals("h264", baseline.videoCodec) + assertEquals("h264,hevc", capable.videoCodec) + assertEquals("aac,mp3", capable.audioCodec) + assertFalse(capable.videoCodec.contains("av1")) + assertFalse(capable.audioCodec.contains("eac3")) + } + + @Test + fun detailedProfileAllowsVideoStreamCopyWithinDecoderLimits() { + val profile = DeviceProfile.embyAndroidTv( + DevicePlaybackCapabilities( + h264 = VideoDecoderCapabilities( + supported = true, + profiles = setOf("baseline", "main", "high"), + mainLevel = 52, + maxWidth = 3840, + maxHeight = 2160, + ), + hevc = VideoDecoderCapabilities( + supported = true, + profiles = setOf("main", "main10"), + mainLevel = 153, + tenBitLevel = 153, + maxWidth = 3840, + maxHeight = 2160, + ), + ), + ) + + assertEquals("h264,hevc", profile.directPlayProfiles.single().videoCodec) + assertEquals("h264,hevc", profile.transcodingProfiles.single().videoCodec) + assertTrue(profile.codecProfiles.any { it.codec == "h264" && it.conditions.any { c -> c.value == "52" } }) + assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.value == "153" } }) + assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.property == "Width" } }) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt index e26778c..64026cf 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt @@ -53,4 +53,17 @@ class ProfileSettingsTest { assertEquals(30, family.forYouMinutes) assertEquals(false, family.hasOpenedForYou) } + + @Test + fun `welcome quote style belongs to each profile`() { + val matt = profile.copy(welcomeQuoteStyle = "homicidal") + val family = profile.copy( + id = "server::family", + userId = "family", + welcomeQuoteStyle = "positive", + ) + + assertEquals("homicidal", matt.welcomeQuoteStyle) + assertEquals("positive", family.welcomeQuoteStyle) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt index 3ef79c9..8fad57e 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt @@ -73,6 +73,19 @@ class RowAnalyticsTest { assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId }) } + @Test + fun `visible posters receive deduplicated item impressions`() { + val collector = analytics(FakeClock()) + + collector.rowImpression("recommended", "MOVIES", listOf("one", "two")) + collector.rowImpression("recommended", "MOVIES", listOf("one", "two")) + + val itemImpressions = collector.drain().filter { + it.event == RowAnalytics.EVENT_IMPRESSION && it.itemId.isNotEmpty() + } + assertEquals(listOf("one", "two"), itemImpressions.map { it.itemId }) + } + @Test fun `focusing a row that was never reported still records the impression`() { val collector = analytics(FakeClock()) diff --git a/app/src/test/java/com/ponzischeme89/memby/data/ServiceAlertTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/ServiceAlertTest.kt index 4c31216..c4be74e 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/ServiceAlertTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/ServiceAlertTest.kt @@ -85,5 +85,28 @@ class ServiceAlertTest { assertEquals("sonarr:7:42:aired", alert.id) assertEquals("sonarr:7:42", alert.itemId) assertEquals("sonarr", alert.imageTag) + // A gateway that predates server-worded banners sends no label at all. + assertEquals("", alert.label) + } + + @Test + fun `status decodes a radarr import alert with its own wording`() { + val status = json.decodeFromString( + """ + {"maintenance":false,"message":"","alerts":[{ + "id":"radarr:412:file:9001","kind":"radarr-import","label":"NEW MOVIE ADDED", + "title":"Mr. Smith Goes to Washington (1939)", + "message":"Mr. Smith Goes to Washington will be available in Emby shortly.", + "itemId":"radarr:412","imageTag":"radarr","airedAt":"2026-07-31T19:04:00Z" + }]} + """.trimIndent(), + ) + val alert = status.alerts.single() + assertEquals("radarr:412:file:9001", alert.id) + assertEquals("NEW MOVIE ADDED", alert.label) + // The image proxy serves Radarr covers under this pair, so the banner has a + // poster before Emby has scanned the film in. + assertEquals("radarr:412", alert.itemId) + assertEquals("radarr", alert.imageTag) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/SessionValidationTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/SessionValidationTest.kt new file mode 100644 index 0000000..71a46bb --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/SessionValidationTest.kt @@ -0,0 +1,29 @@ +package com.ponzischeme89.memby.data + +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +class SessionValidationTest { + @Test + fun `server restart preserves saved session`() { + assertTrue(shouldPreserveSessionAfterValidationFailure(IOException("offline"))) + } + + @Test + fun `maintenance response preserves saved session`() { + assertTrue(shouldPreserveSessionAfterValidationFailure(httpFailure(503))) + } + + @Test + fun `explicit unauthorized response may invalidate saved session`() { + assertFalse(shouldPreserveSessionAfterValidationFailure(httpFailure(401))) + } + + private fun httpFailure(code: Int): HttpException = + HttpException(Response.error(code, "error".toResponseBody())) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/SettingsStoreProfileRemovalTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/SettingsStoreProfileRemovalTest.kt new file mode 100644 index 0000000..9511ef1 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/SettingsStoreProfileRemovalTest.kt @@ -0,0 +1,46 @@ +package com.ponzischeme89.memby.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsStoreProfileRemovalTest { + @Test + fun `ten minute reminder preference is persisted`() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val store = SettingsStore(context) + + store.setShowTenMinuteReminder(false) + + assertFalse(store.snapshot().showTenMinuteReminder) + } + + @Test + fun `removing profiles preserves other users and clears only an active session`() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val store = SettingsStore(context) + + store.saveSession("https://example.test", "token-a", "user-a", "Alex", "server") + val alex = store.snapshot().profiles.single() + store.saveSession("https://example.test", "token-b", "user-b", "Bailey", "server") + val bailey = store.snapshot().profiles.single { it.userId == "user-b" } + + store.removeProfile(alex.id) + val afterInactiveRemoval = store.snapshot() + assertEquals(listOf(bailey.id), afterInactiveRemoval.profiles.map { it.id }) + assertTrue(afterInactiveRemoval.isSignedIn) + assertEquals("user-b", afterInactiveRemoval.userId) + + store.removeProfile(bailey.id) + val afterActiveRemoval = store.snapshot() + assertTrue(afterActiveRemoval.profiles.isEmpty()) + assertFalse(afterActiveRemoval.isSignedIn) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/AiringTodayTagsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/AiringTodayTagsTest.kt index a342033..3ec8ba4 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/AiringTodayTagsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/AiringTodayTagsTest.kt @@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class AiringTodayTagsTest { @Test - fun `tags matching recommended series from today's Sonarr schedule`() { + fun `tags matching recommended series from today's TV schedule`() { val home = HomeSnapshot( rows = listOf( HomeRow( @@ -22,6 +23,7 @@ class AiringTodayTagsTest { name = "The Bear", type = "MembySonarrEpisode", membySource = "sonarr", + membyAirDayLabel = "Today", ), ), ), @@ -56,6 +58,7 @@ class AiringTodayTagsTest { name = "Marvel's DAREDEVIL", type = "MembySonarrEpisode", membySource = "sonarr", + membyAirDayLabel = "Today", ), ), ), @@ -85,6 +88,7 @@ class AiringTodayTagsTest { name = "Fargo", type = "MembySonarrEpisode", membySource = "sonarr", + membyAirDayLabel = "Today", ), ), ), @@ -98,4 +102,80 @@ class AiringTodayTagsTest { assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday) } + + @Test + fun `only today's scheduled show is propagated to other rows`() { + val home = HomeSnapshot( + rows = listOf( + HomeRow( + id = "sonarr-airing-today", + title = "Shows airing in the next 5 days", + kind = "schedule", + items = listOf( + BaseItem( + id = "sonarr:1:1", + name = "Today Show", + type = "MembySonarrEpisode", + membySource = "sonarr", + membyAirDayLabel = "Today", + ), + BaseItem( + id = "sonarr:2:1", + name = "Tomorrow Show", + type = "MembySonarrEpisode", + membySource = "sonarr", + membyAirDayLabel = "Tomorrow", + ), + ), + ), + HomeRow( + id = "recommended", + title = "Recommended", + items = listOf( + BaseItem(id = "series-1", name = "Today Show", type = "Series"), + BaseItem(id = "series-2", name = "Tomorrow Show", type = "Series"), + ), + ), + ), + ) + + val recommendations = home.withAiringTodayTags().rows.last().items + assertTrue(recommendations[0].membyAiringToday) + assertFalse(recommendations[1].membyAiringToday) + } + + @Test + fun `schedule poster badges use each server-authored day`() { + fun scheduled(day: String) = BaseItem( + id = day, + name = "Show", + type = "MembySonarrEpisode", + membySource = "sonarr", + membyAirDayLabel = day, + membyAiringToday = true, + ) + + assertEquals("TODAY", airingBadgeLabel(scheduled("Today"))) + assertEquals("TOMORROW", airingBadgeLabel(scheduled("Tomorrow"))) + assertEquals("FRIDAY", airingBadgeLabel(scheduled("Friday"))) + } + + @Test + fun `Radarr movie schedule uses its server-authored release day badge`() { + val movie = BaseItem( + id = "radarr:7", + name = "Arrival", + type = "MembyRadarrMovie", + membySource = "radarr", + membyAirDayLabel = "Saturday", + ) + + assertEquals("SATURDAY", airingBadgeLabel(movie)) + } + + @Test + fun `upcoming schedule status does not claim the show airs today`() { + assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming")) + assertEquals("UPCOMING", scheduleStatusBadgeLabel("")) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/DetailFranchiseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/DetailFranchiseTest.kt new file mode 100644 index 0000000..8e60c23 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/DetailFranchiseTest.kt @@ -0,0 +1,34 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.detail.franchiseStart +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DetailFranchiseTest { + private fun movie(id: String, year: Int, collection: String? = "The Saga") = BaseItem( + id = id, + name = "Movie $id", + type = "Movie", + productionYear = year, + collectionName = collection, + ) + + @Test + fun `earliest movie in the same collection starts the franchise`() { + val current = movie("three", 2022) + val start = franchiseStart( + current, + listOf(movie("two", 2018), movie("one", 2014), movie("other", 2001, "Other")), + ) + + assertEquals("The Saga", start?.name) + assertEquals("one", start?.firstMovie?.id) + } + + @Test + fun `a collection name without a sibling is not presented as a franchise`() { + assertNull(franchiseStart(movie("only", 2020), emptyList())) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/DetailNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/DetailNavigationTest.kt new file mode 100644 index 0000000..1d261a8 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/DetailNavigationTest.kt @@ -0,0 +1,121 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.ui.detail.DetailPosition +import com.ponzischeme89.memby.ui.detail.DetailPositionStore +import com.ponzischeme89.memby.ui.detail.DetailTab +import com.ponzischeme89.memby.ui.detail.DetailZone +import com.ponzischeme89.memby.ui.detail.detailTab +import com.ponzischeme89.memby.ui.detail.detailTabs +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The two rules the detail pages depend on and cannot check on a device: the tab strip is + * decided by what the item *is*, and a page's position survives being closed. + */ +class DetailNavigationTest { + + @Test + fun `a movie always offers the same three tabs`() { + assertEquals( + listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS), + detailTabs(isSeries = false), + ) + } + + @Test + fun `a series always offers episodes`() { + assertEquals( + listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS), + detailTabs(isSeries = true), + ) + } + + /** + * The regression this replaced: the strip used to be built from what had loaded, so a + * movie opened with one tab and grew two more when its metadata arrived — moving the + * strip under whatever the viewer was already pressing. + */ + @Test + fun `the strip does not depend on loaded metadata`() { + assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false)) + assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true)) + } + + @Test + fun `an episodes key remembered from a series falls back on a movie`() { + assertEquals( + DetailTab.OVERVIEW, + detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)), + ) + assertEquals( + DetailTab.EPISODES, + detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)), + ) + } + + @Test + fun `an unknown key falls back to overview`() { + assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true))) + } + + @Test + fun `a page reopens where it was left`() { + val store = DetailPositionStore() + store.update("show-1") { + it.copy(tabKey = DetailTab.EPISODES.key, season = 3, zone = DetailZone.CONTENT) + } + store.update("show-1") { it.copy(episodeIndex = 6) } + + assertEquals( + DetailPosition( + tabKey = DetailTab.EPISODES.key, + season = 3, + zone = DetailZone.CONTENT, + episodeIndex = 6, + ), + store.get("show-1"), + ) + } + + @Test + fun `an unvisited page opens on overview with focus on play`() { + val position = DetailPositionStore().get("never-opened") + + assertEquals(DetailTab.OVERVIEW.key, position.tabKey) + assertEquals(DetailZone.PLAY, position.zone) + assertEquals(null, position.season) + } + + @Test + fun `play focus pins the complete hero while lower zones release scrolling`() { + assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY)) + assertEquals(null, detailHeroScrollTarget(DetailZone.TABS)) + assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT)) + assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED)) + } + + @Test + fun `the store is capped and keeps the most recently used`() { + val store = DetailPositionStore(maxEntries = 3) + listOf("a", "b", "c").forEach { id -> + store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) } + } + // Touching "a" makes it the newest, so "b" is what falls out. + store.get("a") + store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) } + + assertEquals(3, store.size()) + assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey) + assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey) + assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey) + } + + @Test + fun `a blank item id is never stored`() { + val store = DetailPositionStore() + store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) } + + assertEquals(0, store.size()) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/DetailPageScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/DetailPageScreenshotTest.kt new file mode 100644 index 0000000..712ad6d --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/DetailPageScreenshotTest.kt @@ -0,0 +1,359 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.RelatedContent +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.EmbyPerson +import com.ponzischeme89.memby.data.model.MediaStream +import com.ponzischeme89.memby.data.model.Studio +import com.ponzischeme89.memby.data.model.UserItemData +import com.ponzischeme89.memby.ui.detail.creditRows +import com.ponzischeme89.memby.ui.detail.technicalSpecs +import org.junit.Before +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 the movie and series detail pages to PNGs under `build/screenshots/`. + * + * ```powershell + * .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest" + * ``` + * + * There is no network here, so every artwork URL resolves to null and the pages render on + * their own scrims. That is the point: it is the check that the poster column, the fact row + * and the episode strip hold their shape when Emby has no backdrop and no poster to give — + * the worst case, and the one a real library hits often enough to matter. + * + * Like [ServiceAlertBannerScreenshotTest], this is a `*ScreenshotTest.kt` file and is + * allowed the Robolectric dependency; what these pages *say* lives in pure functions in + * `ui/detail/DetailFacts.kt` and is covered by [SeriesDetailsTest] in plain JUnit. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class DetailPageScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + // The hero asks the repository for its artwork URLs; without a token it answers + // null, which is exactly the fallback case being captured. + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `movie page`() { + capture("df_detail-movie") { + MediaDetailContent( + item = movie, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + onTogglePlayed = { _, _ -> }, + related = related, + ) + } + } + + /** Part-watched: the progress bar and "Resume" wording only appear in this state. */ + @Test + fun `movie page part watched`() { + capture("df_detail-movie-resumable") { + MediaDetailContent( + item = movie.copy( + userData = UserItemData(playbackPositionTicks = 47L * 600_000_000L), + ), + onPlay = {}, + onToggleFavorite = { _, _ -> }, + onTogglePlayed = { _, _ -> }, + related = related, + ) + } + } + + @Test + fun `series page`() { + capture("df_detail-series") { + SeriesDetailContent( + item = series, + episodes = episodes, + loadFailed = false, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + isMyShow = false, + onToggleMyShow = { _, _ -> }, + related = related, + ) + } + } + + /** The first frame, before the episode request lands. The header must already be whole. */ + @Test + fun `series page loading`() { + capture("df_detail-series-loading") { + SeriesDetailContent( + item = series, + episodes = null, + loadFailed = false, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + isMyShow = true, + onToggleMyShow = { _, _ -> }, + ) + } + } + + /** + * The other panes. Clicking a tab is the same path a remote takes — the strip selects + * on focus — so this also proves the panes swap without disturbing the header. + */ + @Test + fun `series episodes tab`() { + captureTab("df_detail-series-episodes", "Episodes") { + SeriesDetailContent( + item = series, + episodes = episodes, + loadFailed = false, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + isMyShow = false, + onToggleMyShow = { _, _ -> }, + related = related, + ) + } + } + + @Test + fun `series cast tab`() { + captureTab("df_detail-series-cast-details", "Cast & Details") { + SeriesDetailContent( + item = series, + episodes = episodes, + loadFailed = false, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + isMyShow = false, + onToggleMyShow = { _, _ -> }, + related = related, + ) + } + } + + @Test + fun `movie details tab`() { + captureTab("df_detail-movie-more-like-this", "More Like This") { + MediaDetailContent( + item = movie, + onPlay = {}, + onToggleFavorite = { _, _ -> }, + onTogglePlayed = { _, _ -> }, + related = related, + ) + } + } + + /** + * The tab panes at the geometry the scaffold gives them on a 540dp TV — the slot the + * audit found clipping every technical spec off the bottom of Cast & Details, which is + * the whole reason that tab exists. A tab does not scroll, so everything it has to say + * has to be inside this box. + */ + @Test + fun `cast and details pane at its real slot geometry`() { + capturePane("df_detail-pane-cast-details") { + DetailCastAndDetailsPane( + item = movie, + credits = creditRows(movie), + specs = technicalSpecs(movie), + detailsLoaded = true, + focusRequester = FocusRequester(), + ) + } + } + + @Test + fun `overview pane at its real slot geometry`() { + capturePane("df_detail-pane-overview") { + DetailOverviewPane( + item = series, + credits = creditRows(series), + focusRequester = FocusRequester(), + supportingText = "Up next S1 E3 · The Long Count", + ) + } + } + + private fun capturePane(name: String, pane: @Composable () -> Unit) { + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = DetailSideGutter, vertical = 16.dp) + .height(detailPaneHeight(540.dp)), + ) { pane() } + } + } + compose.onRoot().captureRoboImage("build/screenshots/$name.png") + } + + private fun capture(name: String, content: @Composable () -> Unit) { + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { content() } + } + compose.onRoot().captureRoboImage("build/screenshots/$name.png") + } + + private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) { + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { content() } + } + compose.onNodeWithText(tab).performClick() + compose.onRoot().captureRoboImage("build/screenshots/$name.png") + } + + /** + * What the gateway returns for a warm profile: the reason strip above the tabs and the + * carousel under the page. Both are the point of these captures now — they are the two + * bands that decide whether the page still fits on one screen. + */ + private val related = RelatedContent( + reasons = listOf( + "Because you watch Thriller", + "You've watched Aria Vance before", + "Well rated (8.4)", + ), + items = List(8) { index -> + BaseItem( + id = "related-$index", + name = listOf( + "The Quiet Meridian", + "Harbour Lights", + "Nine Days of Rain", + "The Cartographer's Wife", + "Winterline", + "A Country of Small Rivers", + "The Last Broadcast", + "Northbound", + )[index], + type = "Movie", + productionYear = 2019 + index % 5, + ) + }, + ) + + private val cast = listOf( + person("Aria Vance", "Detective Iris Kell"), + person("Marcus Oyelaran", "Samuel Reed"), + person("Nina Kowalczyk", "Dr. Halvorsen"), + person("Tomas Brandt", "The Cartographer"), + person("Ines Ferreira", "Captain Ruiz"), + person("Daniel Cho", "Weatherman"), + ) + + private val streams = listOf( + MediaStream( + type = "Video", + codec = "hevc", + width = 3840, + height = 2160, + videoRange = "HDR", + videoRangeType = "HDR10", + ), + MediaStream(type = "Audio", codec = "eac3", channels = 6, language = "eng", title = "Dolby Atmos"), + MediaStream(type = "Subtitle", codec = "subrip", language = "eng"), + MediaStream(type = "Subtitle", codec = "subrip", language = "fra"), + ) + + private val movie = BaseItem( + id = "movie-1", + name = "The Longest Northbound Winter", + type = "Movie", + overview = "A cartographer chasing a river that no longer exists finds the last " + + "village on the map still waiting for him, and has to decide whether telling " + + "them the truth is a kindness. Shot over four winters in a valley that floods " + + "every spring, and assembled from what survived.", + taglines = listOf("Some maps are promises."), + productionYear = 2024, + officialRating = "PG-13", + communityRating = 8.4, + genres = listOf("Drama", "Adventure", "Mystery"), + runTimeTicks = 134L * 600_000_000L, + studios = listOf(Studio(name = "Northlight Pictures"), Studio(name = "Kestrel")), + mediaStreams = streams, + people = cast, + ) + + private val series = BaseItem( + id = "series-1", + name = "Signal Hill", + type = "Series", + overview = "A coastal radio station keeps receiving a broadcast that has not been " + + "transmitted yet. Six weeks before the storm, the night operator starts writing " + + "down what she hears.", + taglines = listOf("Listen closely."), + productionYear = 2022, + officialRating = "TV-MA", + communityRating = 8.9, + genres = listOf("Thriller", "Drama"), + studios = listOf(Studio(name = "Harbour Line")), + mediaStreams = streams, + people = cast, + ) + + private val episodes = listOf( + episode(1, 1, "Carrier Wave", played = true), + episode(1, 2, "Dead Air", played = true), + episode(1, 3, "The Long Count", position = 12L * 600_000_000L), + episode(1, 4, "Nightingale"), + episode(1, 5, "Six Weeks Out"), + episode(2, 1, "Landfall"), + ) + + private fun episode( + season: Int, + number: Int, + title: String, + played: Boolean = false, + position: Long = 0L, + ) = BaseItem( + id = "s${season}e$number", + name = title, + type = "Episode", + seriesName = "Signal Hill", + parentIndexNumber = season, + indexNumber = number, + runTimeTicks = 48L * 600_000_000L, + overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " + + "and the log book from 1974 says the same thing happened before.", + userData = UserItemData(played = played, playbackPositionTicks = position), + ) + + private fun person(name: String, role: String) = EmbyPerson( + id = name.filter(Char::isLetter), + name = name, + role = role, + type = "Actor", + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt new file mode 100644 index 0000000..ce1f304 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -0,0 +1,191 @@ +package com.ponzischeme89.memby.ui + +import android.graphics.BitmapFactory +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import androidx.tv.material3.Text +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import org.junit.Before +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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class HomeMovieHeroScreenshotTest { + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `home hero with popular and new releases`() { + capture("df_home-movie-hero", movies) + } + + /** + * The case the audit reproduced: with a title long enough to wrap, the card's content + * column overflowed its fixed height and the green Play chip — the last thing in the + * column — was clipped away entirely. It has to survive here. + */ + @Test + fun `home hero with a wrapping title`() { + capture( + "df_home-movie-hero-long-title", + 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", + ), + ) + movies.drop(1), + ) + } + + private fun capture(name: String, movies: List) { + val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png")) + .use(BitmapFactory::decodeStream) + .asImageBitmap() + val railFocus = FocusRequester() + + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { + Row(Modifier.fillMaxSize()) { + TvNavigationRail( + selected = BrowseDestination.HOME, + expanded = false, + navigationFocusRequester = railFocus, + onRailFocusChanged = {}, + onDestinationSelected = {}, + ) + Column(Modifier.weight(1f).fillMaxHeight()) { + HomeMovieHero( + movies = movies, + navigationFocusRequester = railFocus, + onItemFocused = {}, + onItemSelected = {}, + modifier = Modifier.height(homeHeaderHeight(540.dp, showHero = true)), + previewArtwork = artwork, + ) + Text( + "Recently added movies", + color = Color(0xFFF1F3F4), + fontSize = 18.sp, + modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(5) { index -> + Column(Modifier.weight(1f)) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(7.dp)) + .background( + listOf( + Color(0xFF27343D), Color(0xFF28302D), + Color(0xFF352C37), Color(0xFF392F28), Color(0xFF25343A), + )[index], + ), + ) + Text( + movies[index % movies.size].item.name, + color = Color.White, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + Text( + "2026 • 2h 4m", + color = Color(0xFF8F9AA3), + fontSize = 11.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + } + } + } + + compose.onNodeWithText("Play").fetchSemanticsNode() + compose.onRoot().captureRoboImage("build/screenshots/$name.png") + } + + private val movies = listOf( + HomeHeroPick( + movie( + "The Last Horizon", + 2026, + "Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.", + 8.7, + ), + "NEW RELEASE", + ), + HomeHeroPick( + movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2), + "POPULAR", + ), + HomeHeroPick( + movie("Northstar Run", 2025, "The final supply ship takes an impossible route.", 7.9), + "NEW RELEASE", + ), + HomeHeroPick( + movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4), + "FROM YOUR LIBRARY", + ), + ) + + private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem( + id = name.lowercase().replace(' ', '-'), + name = name, + type = "Movie", + overview = overview, + productionYear = year, + officialRating = "M", + communityRating = rating, + runTimeTicks = 124L * 600_000_000L, + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt new file mode 100644 index 0000000..5fb27bf --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt @@ -0,0 +1,75 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.ui.unit.dp +import com.ponzischeme89.memby.data.model.BaseItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeMovieHeroTest { + @Test + fun `home hero gives way to focused row metadata`() { + assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = null)) + assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = HOME_HERO_ROW_ID)) + assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, focusedRowId = "continue")) + assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, focusedRowId = null)) + } + + @Test + fun `home hero leaves room for a complete shelf on a 540dp tv`() { + val heroHeight = homeHeaderHeight(540.dp, showHero = true) + + assertEquals(248.4f, heroHeight.value, 0.01f) + assertTrue(540.dp - heroHeight >= 288.dp) + } + + @Test + fun `hero alternates new releases and popular movies`() { + val rows = listOf( + row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"), + row("popular", "Popular Movies", "popular-1", "popular-2"), + ) + + assertEquals( + listOf("new-1", "popular-1", "new-2", "popular-2"), + selectHomeHeroMovies(rows).map { it.item.id }, + ) + } + + /** + * The caption used to be the card's slot, so the third card was always "TRENDING" + * whatever it was. It now names the row the title was actually drawn from. + */ + @Test + fun `hero labels each pick by the row it came from`() { + val rows = listOf( + row("latest-movies", "Recently Added Movies", "new-1"), + row("popular", "Popular Movies", "popular-1"), + row("comedy", "Comedy", "other-1"), + ) + + assertEquals( + listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"), + selectHomeHeroMovies(rows).map { it.label }, + ) + } + + @Test + fun `hero removes repeated movies across source rows`() { + val rows = listOf( + row("latest", "New releases", "shared", "new-2"), + row("recommended", "Recommended", "shared", "popular-2", "popular-3"), + ) + + assertEquals(4, selectHomeHeroMovies(rows).size) + assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size) + } + + private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow( + id = id, + title = title, + items = ids.map { BaseItem(id = it, name = it, type = "Movie") }, + kind = MediaRowKind.MOVIES, + emptyMessage = "", + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt index 793d3bd..18d108a 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt @@ -28,6 +28,33 @@ class MediaBadgesTest { ) } + /** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */ + @Test + fun `names HDR10+ rather than collapsing it to HDR`() { + val item = BaseItem( + id = "movie", + mediaStreams = listOf( + MediaStream(type = "Video", width = 3840, videoRangeType = "HDR10+"), + ), + ) + + assertEquals(listOf("4K", "HDR10+"), mediaBadges(item)) + } + + /** + * The badge and the spec row's `(4K)` suffix used to disagree — 3800 against 3400 — + * so a 3600-wide file was 4K on the detail page and not on the card. + */ + @Test + fun `uses the same 4K threshold as the technical spec row`() { + fun badgesAt(width: Int) = mediaBadges( + BaseItem(id = "m", mediaStreams = listOf(MediaStream(type = "Video", width = width))), + ) + + assertEquals(emptyList(), badgesAt(3_600)) + assertEquals(listOf("4K"), badgesAt(3_840)) + } + @Test fun `returns no badges when stream metadata is unavailable`() { assertEquals(emptyList(), mediaBadges(BaseItem(id = "unknown"))) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MyShowsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MyShowsTest.kt new file mode 100644 index 0000000..947c514 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MyShowsTest.kt @@ -0,0 +1,29 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.MyShow +import org.junit.Assert.assertEquals +import org.junit.Test + +class MyShowsTest { + @Test + fun missingNextEpisodeHasFriendlyCopy() { + assertEquals("Not announced", formatMyShowDate(null)) + assertEquals("Not announced", formatMyShowDate("not-a-date")) + } + + @Test + fun cardSubtitlePrioritisesUsefulShowState() { + assertEquals( + "Cancelled", + myShowCardSubtitle(MyShow(itemId = "1", title = "Ended", lifecycle = "Cancelled")), + ) + assertEquals( + "Not monitored", + myShowCardSubtitle(MyShow(itemId = "2", title = "Paused", sonarrStatus = "Not monitored")), + ) + assertEquals( + "Saved show", + myShowCardSubtitle(MyShow(itemId = "3", title = "Unknown")), + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/QuickActionsNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/QuickActionsNavigationTest.kt new file mode 100644 index 0000000..0a30567 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/QuickActionsNavigationTest.kt @@ -0,0 +1,29 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuickActionsNavigationTest { + @Test + fun `quick actions require an intentional hold`() { + assertTrue(QuickActionsHoldDurationMillis >= 600L) + } + + @Test + fun `down reaches every action and stops at the final action`() { + assertEquals(1, quickActionNextIndex(0, 4, QuickActionDirection.DOWN)) + assertEquals(3, quickActionNextIndex(3, 4, QuickActionDirection.DOWN)) + } + + @Test + fun `up returns toward the safe first action and stops there`() { + assertEquals(2, quickActionNextIndex(3, 4, QuickActionDirection.UP)) + assertEquals(0, quickActionNextIndex(0, 4, QuickActionDirection.UP)) + } + + @Test + fun `empty menus remain bounded`() { + assertEquals(0, quickActionNextIndex(5, 0, QuickActionDirection.DOWN)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt new file mode 100644 index 0000000..385ded7 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt @@ -0,0 +1,54 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ResponsiveRowSizingTest { + @Test + fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() { + assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f) + assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f) + } + + @Test + fun standardPosterRowFitsSevenCompleteCards() { + val width = responsiveRowCardWidth( + availableWidth = 1280.dp, + preferredCardsAcross = 7, + minWidth = 102.dp, + maxWidth = 218.dp, + ) + + val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2 + assertEquals(1280f, occupiedWidth.value, 0.01f) + } + + @Test + fun narrowRowReducesCardCountInsteadOfClippingCards() { + val width = responsiveRowCardWidth( + availableWidth = 640.dp, + preferredCardsAcross = 7, + minWidth = 102.dp, + maxWidth = 218.dp, + ) + + // Four 130dp cards, three gaps and both insets fill this viewport exactly. + assertEquals(130f, width.value, 0.01f) + assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f) + } + + @Test + fun wideRowAddsCardsRatherThanExceedingMaximumWidth() { + val width = responsiveRowCardWidth( + availableWidth = 2560.dp, + preferredCardsAcross = 4, + minWidth = 164.dp, + maxWidth = 360.dp, + ) + + assertTrue(width <= 360.dp) + assertEquals(341.71f, width.value, 0.01f) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/RowNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/RowNavigationTest.kt new file mode 100644 index 0000000..d9f4805 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/RowNavigationTest.kt @@ -0,0 +1,73 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RowNavigationTest { + @Test + fun `vertical movement skips rows without focusable cards`() { + val counts = listOf(6, 0, 0, 4) + + assertEquals( + 3, + adjacentFocusableRowIndex(counts, 0, RowFocusDirection.DOWN), + ) + assertEquals( + 0, + adjacentFocusableRowIndex(counts, 3, RowFocusDirection.UP), + ) + } + + @Test + fun `vertical movement stops at the page boundary`() { + val counts = listOf(3, 2) + + assertNull(adjacentFocusableRowIndex(counts, 0, RowFocusDirection.UP)) + assertNull(adjacentFocusableRowIndex(counts, 1, RowFocusDirection.DOWN)) + } + + @Test + fun `vertical movement can traverse beyond the second row`() { + val counts = listOf(6, 6, 6, 6, 6) + val visited = mutableListOf(0) + var current = 0 + + while (true) { + current = adjacentFocusableRowIndex( + counts, + current, + RowFocusDirection.DOWN, + ) ?: break + visited += current + } + + assertEquals(listOf(0, 1, 2, 3, 4), visited) + } + + @Test + fun `first visit keeps horizontal position and clamps to a shorter row`() { + assertEquals(4, rowEntryItemIndex(4, destinationItemCount = 8, null)) + assertEquals(2, rowEntryItemIndex(7, destinationItemCount = 3, null)) + } + + @Test + fun `return visit restores the destination row position`() { + assertEquals( + 1, + rowEntryItemIndex( + sourceIndex = 5, + destinationItemCount = 8, + rememberedDestinationIndex = 1, + ), + ) + assertEquals( + 2, + rowEntryItemIndex( + sourceIndex = 1, + destinationItemCount = 3, + rememberedDestinationIndex = 20, + ), + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt index bdd6b8f..bb2800a 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/SeriesDetailsTest.kt @@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.UserItemData +import com.ponzischeme89.memby.ui.detail.availableSeasons +import com.ponzischeme89.memby.ui.detail.defaultSeason +import com.ponzischeme89.memby.ui.detail.episodesForSeason +import com.ponzischeme89.memby.ui.detail.seasonLabel import org.junit.Assert.assertEquals import org.junit.Test @@ -55,9 +59,8 @@ class SeriesDetailsTest { } @Test - fun `detail tabs default safely to episodes`() { - assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes")) - assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast")) - assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section")) + fun `season zero is labelled specials`() { + assertEquals("Specials", seasonLabel(0)) + assertEquals("Season 3", seasonLabel(3)) } } 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 bddd342..85d896b 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt @@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.UserItemData import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -15,6 +16,28 @@ import org.junit.Test * the server invented, and surviving a cold start from cache. */ class ServerHomeRowsTest { + @Test + fun `profile row preferences hide pin and order server rows`() { + val serverRows = listOf( + HomeRow("continue", "Continue", "continue", emptyList()), + HomeRow("recommended", "Recommended", "recommendation", emptyList()), + HomeRow("latest-movies", "Latest", "latest", emptyList()), + HomeRow("seasonal", "Seasonal", "recommendation", emptyList()), + ) + val settings = Settings( + homeRowOrder = "seasonal\nrecommended\ncontinue", + homePinnedRows = "recommended", + homeHiddenRows = "latest-movies", + ) + + val result = serverHomeRows( + HomeUiState(rows = serverRows, loading = emptySet()), + settings, + ) + + assertEquals(listOf("recommended", "seasonal", "continue"), result.map { it.id }) + } + private val json = Json { ignoreUnknownKeys = true } @@ -73,10 +96,7 @@ class ServerHomeRowsTest { @Test fun `successful login welcome uses authenticated username`() { - assertEquals( - "You're now logged in as Matt. Welcome to Memby!", - loginWelcomeMessage(" Matt "), - ) + assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. ")) } @Test @@ -189,7 +209,61 @@ class ServerHomeRowsTest { } @Test - fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() { + fun `home and library destinations have distinct shelves`() { + val state = HomeUiState( + rows = serverRows + listOf( + row("curated:drama-shows", "shows", "show"), + row("curated:movies:genre:drama", "movies", "movie"), + ), + loading = emptySet(), + ) + + val home = homeRowsFor(BrowseDestination.HOME, state, Settings()) + val shows = homeRowsFor(BrowseDestination.SHOWS, state, Settings()) + val movies = homeRowsFor(BrowseDestination.MOVIES, state, Settings()) + + assertTrue(home.none { it.id.startsWith("curated:") }) + assertTrue(shows.any { it.id == "curated:drama-shows" }) + assertTrue(movies.any { it.id == "curated:movies:genre:drama" }) + } + + @Test + fun `discovery shelves do not repeat a continued series or played title`() { + val continued = BaseItem(id = "episode", type = "Episode", seriesId = "hard-man") + val repeatedSeries = BaseItem(id = "hard-man", type = "Series") + val playedMovie = BaseItem( + id = "played", + type = "Movie", + userData = UserItemData(played = true), + ) + val fresh = (1..4).map { BaseItem(id = "fresh-$it", type = "Series") } + val rows = deduplicateBrowseRows( + listOf( + HomeBrowseRow( + id = "continue", + title = "Continue", + items = listOf(continued), + kind = MediaRowKind.CONTINUE, + emptyMessage = "empty", + ), + HomeBrowseRow( + id = "curated:drama-shows", + title = "Drama", + items = listOf(repeatedSeries, playedMovie) + fresh, + kind = MediaRowKind.SHOWS, + emptyMessage = "empty", + ), + ), + ) + + assertEquals( + fresh.map { it.id }, + rows.last().items.map { it.id }, + ) + } + + @Test + fun `TV schedule rows use show cards and cannot be hidden by old preferences`() { val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42") val rows = serverHomeRows( @@ -199,7 +273,24 @@ class ServerHomeRowsTest { assertEquals(1, rows.size) assertEquals(MediaRowKind.SHOWS, rows.single().kind) - assertEquals("No monitored shows are airing today", rows.single().emptyMessage) + assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage) + } + + @Test + fun `Radarr schedule rows use movie cards and explain an empty digital window`() { + val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7") + + val rows = serverHomeRows( + HomeUiState(rows = listOf(schedule), loading = emptySet()), + Settings(homeSections = ""), + ) + + assertEquals(1, rows.size) + assertEquals(MediaRowKind.MOVIES, rows.single().kind) + assertEquals( + "No monitored movies have a digital release in the next 5 days", + rows.single().emptyMessage, + ) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ServiceAlertBannerScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ServiceAlertBannerScreenshotTest.kt index 2119eda..255879e 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ServiceAlertBannerScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ServiceAlertBannerScreenshotTest.kt @@ -1,7 +1,14 @@ package com.ponzischeme89.memby.ui +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage @@ -25,8 +32,8 @@ import org.robolectric.annotation.GraphicsMode * composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files * named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way. * - * Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's - * own background, flush to the top edge exactly as `MainActivity` places it. + * Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic + * cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it. * * [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole * job is the drop-in from above, and a still frame of an animation says nothing. Posters @@ -49,7 +56,6 @@ class ServiceAlertBannerScreenshotTest { id = "sonarr:7:42:aired", title = "Northbound", message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.", - posterUrl = null, ), ) } @@ -62,7 +68,54 @@ class ServiceAlertBannerScreenshotTest { id = "sonarr:8:43:aired", title = "The Long Dark", message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.", - posterUrl = null, + ), + ) + } + + /** + * A Radarr import, which is the case with a server-supplied eyebrow: the longest + * label the bar is expected to carry has to leave the countdown ring room. + */ + @Test + fun `new movie added`() { + capture( + "alert-banner-movie-added", + ServiceAlert( + id = "radarr:412:file:9001", + title = "Mr. Smith Goes to Washington (1939)", + message = "Mr. Smith Goes to Washington will be available in Emby shortly.", + label = "NEW MOVIE ADDED", + ), + ) + } + + /** The service itself talking: a finished refresh, with nothing to illustrate it. */ + @Test + fun `library updated`() { + capture( + "alert-banner-library-updated", + ServiceAlert( + id = "library:1785012345", + title = "24 titles added or updated", + message = "Memby has finished refreshing — it is on the home screen now.", + label = "LIBRARY UPDATED", + ), + ) + } + + /** + * The alert most likely to be read over a film, and the one whose wording has to work + * with the picture already stalled behind it. + */ + @Test + fun `server not responding`() { + capture( + "alert-banner-server-down", + ServiceAlert( + id = "emby:down:1785012345", + title = "Emby has stopped communicating", + message = "Playback may stop until it is back. Memby will say when it returns.", + label = "SERVER NOT RESPONDING", ), ) } @@ -78,7 +131,6 @@ class ServiceAlertBannerScreenshotTest { message = "S11E03 — The One Where Absolutely Everything Happens At Once " + "And Then Some More Happens After That aired at 10:30 PM and is " + "downloading now.", - posterUrl = null, ), ) } @@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest { id = "sonarr:3:12:aired", title = "Dune", message = "S01E01 aired and will be in Emby soon.", - posterUrl = null, ), ) } @@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest { id = "sonarr:7:42:aired", title = "Northbound", message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.", - posterUrl = null, ), ) } @@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest { @Composable private fun AlertBannerOnHomeBackground(alert: ServiceAlert) { - PreviewSurface(alignment = Alignment.TopCenter) { + val playbackStill = requireNotNull( + javaClass.getResourceAsStream("/playback_alert_preview_still.png"), + ).use(BitmapFactory::decodeStream).asImageBitmap() + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Image( + bitmap = playbackStill, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) AlertBanner(alert) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt new file mode 100644 index 0000000..6353918 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -0,0 +1,40 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UserSwitcherNavigationTest { + @Test + fun `active profile receives initial focus`() { + assertEquals( + 1, + userSwitcherInitialIndex(listOf("one", "two", "three"), "two"), + ) + } + + @Test + fun `three profiles lead to pinned manage action`() { + val profileCount = 3 + + assertEquals( + 3, + userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN), + ) + assertEquals( + 3, + userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN), + ) + } + + @Test + fun `up and down navigation stay inside the switcher`() { + assertEquals(0, userSwitcherNextIndex(0, 3, UserSwitcherDirection.UP)) + assertEquals(2, userSwitcherNextIndex(3, 3, UserSwitcherDirection.UP)) + } + + @Test + fun `manage remains reachable when no profiles exist`() { + assertEquals(0, userSwitcherInitialIndex(emptyList(), null)) + assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityScreenshotTest.kt new file mode 100644 index 0000000..f7c1fe0 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityScreenshotTest.kt @@ -0,0 +1,101 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import androidx.tv.material3.Text +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.UserItemData +import org.junit.Before +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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class WatchedVisibilityScreenshotTest { + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `browse cards hide watched movies and count watched episodes`() { + val visible = applyWatchedVisibility(listOf(row), enabled = true).single() + + compose.setContent { + PreviewSurface { + Column(Modifier.fillMaxSize().padding(48.dp)) { + Text("Because you like great stories", color = Color.White, fontSize = 22.sp) + Row( + modifier = Modifier.padding(top = 18.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + visible.items.forEach { item -> + PortraitMediaCard( + item = item, + availableWidth = 820.dp, + showSecondaryMetadata = true, + onFocused = {}, + onClick = {}, + onLongClick = {}, + density = "large", + showWatchedEpisodeCount = visible.showWatchedEpisodeCount, + ) + } + } + } + } + } + + compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png") + } + + private val row = HomeBrowseRow( + id = "stories", + title = "Stories", + kind = MediaRowKind.MOVIES, + emptyMessage = "Nothing here", + items = listOf( + BaseItem( + id = "seen", + name = "Already Seen", + type = "Movie", + userData = UserItemData(played = true), + ), + BaseItem( + id = "signal", + name = "Signal House", + type = "Series", + recursiveItemCount = 10, + userData = UserItemData(unplayedItemCount = 2), + ), + BaseItem( + id = "winter", + name = "A Long Winter", + type = "Series", + recursiveItemCount = 24, + userData = UserItemData(unplayedItemCount = 19), + ), + BaseItem(id = "new", name = "Not Watched Yet", type = "Movie", productionYear = 2026), + ), + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityTest.kt new file mode 100644 index 0000000..17ba728 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/WatchedVisibilityTest.kt @@ -0,0 +1,66 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.UserItemData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WatchedVisibilityTest { + @Test + fun `preference is disabled by default`() { + assertFalse(com.ponzischeme89.memby.data.Settings().hideWatchedMovies) + } + + @Test + fun `enabled preference hides only completed movies`() { + val watchedMovie = item("movie", "Movie", played = true) + val unwatchedMovie = item("new-movie", "Movie") + val watchedSeries = item("series", "Series", played = true) + val row = row(watchedMovie, unwatchedMovie, watchedSeries) + + val filtered = applyWatchedVisibility(listOf(row), enabled = true).single() + + assertEquals(listOf("new-movie", "series"), filtered.items.map(BaseItem::id)) + assertTrue(filtered.showWatchedEpisodeCount) + } + + @Test + fun `disabled preference preserves row content`() { + val row = row(item("movie", "Movie", played = true)) + + val unchanged = applyWatchedVisibility(listOf(row), enabled = false).single() + + assertEquals(listOf("movie"), unchanged.items.map(BaseItem::id)) + assertFalse(unchanged.showWatchedEpisodeCount) + } + + @Test + fun `series progress uses aggregate episode counts`() { + val series = BaseItem( + id = "series", + type = "Series", + recursiveItemCount = 10, + userData = UserItemData(unplayedItemCount = 2), + ) + + assertEquals("8 of 10 watched", watchedEpisodeCountLabel(series)) + assertNull(watchedEpisodeCountLabel(item("movie", "Movie"))) + } + + private fun item(id: String, type: String, played: Boolean = false) = BaseItem( + id = id, + type = type, + userData = UserItemData(played = played), + ) + + private fun row(vararg items: BaseItem) = HomeBrowseRow( + id = "row", + title = "Row", + items = items.toList(), + kind = MediaRowKind.MOVIES, + emptyMessage = "Empty", + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt new file mode 100644 index 0000000..b11c2ab --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt @@ -0,0 +1,31 @@ +package com.ponzischeme89.memby.ui + +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WelcomeQuotesTest { + @Test + fun `unknown styles safely fall back to neutral`() { + assertEquals( + randomWelcomeQuote("neutral", Random(7)), + randomWelcomeQuote("something-new", Random(7)), + ) + } + + @Test + fun `each style supplies a welcome line`() { + WelcomeQuoteStyle.entries.forEach { style -> + assertTrue(randomWelcomeQuote(style.value, Random(3)).isNotBlank()) + } + } + + @Test + fun `login greeting trims the authenticated username`() { + assertTrue( + loginWelcomeMessage(" Matt ", "positive", Random(1)) + .startsWith("Welcome to Memby, Matt. "), + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt new file mode 100644 index 0000000..aa48a86 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt @@ -0,0 +1,92 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class PlaybackIdentityScreenshotTest { + @Test + fun `colour artwork keeps its original treatment`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val colourLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.rgb(82, 181, 75)) + } + + assertFalse( + makeLogoVisibleOnDarkBackground( + ImageView(activity), + BitmapDrawable(activity.resources, colourLogo), + ), + ) + } + + @Test + fun `black artwork is lightened for dark player surfaces`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val blackLogo = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.BLACK) + } + + assertTrue( + makeLogoVisibleOnDarkBackground( + ImageView(activity), + BitmapDrawable(activity.resources, blackLogo), + ), + ) + } + + @Test + fun `plain title and emby mark appear over playback for five seconds`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity) + val backdrop = ImageView(activity).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + setImageBitmap( + javaClass.classLoader + ?.getResourceAsStream("home_hero_preview_art.png") + ?.use(BitmapFactory::decodeStream), + ) + } + root.addView( + backdrop, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + val identity = LayoutInflater.from(activity) + .inflate(R.layout.player_playback_identity, root, false) + .apply { + visibility = View.VISIBLE + alpha = 1f + } + identity.findViewById(R.id.player_playback_identity_title).text = "Dark Matter" + root.addView(identity) + activity.setContentView(root) + + assertEquals(5_000L, PlayerActivity.PLAYBACK_IDENTITY_VISIBLE_MS) + root.captureRoboImage("build/screenshots/player-playback-identity.png") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt index c4b66a2..faf98ae 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt @@ -19,13 +19,14 @@ class PlaybackRecoveryTest { } @Test - fun decoderFailuresRequireViewerAction() { + fun decoderFailuresAutomaticallyRequestCompatibleStream() { val failure = describePlaybackFailure( PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, ) assertEquals("Video format not supported", failure.title) - assertFalse(failure.canAutoRetry) + assertTrue(failure.canAutoRetry) + assertTrue(failure.requiresTranscode) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerPauseOverlayScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerPauseOverlayScreenshotTest.kt new file mode 100644 index 0000000..e6cbd64 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerPauseOverlayScreenshotTest.kt @@ -0,0 +1,90 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.Activity +import android.graphics.BitmapFactory +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 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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class PlayerPauseOverlayScreenshotTest { + @Test + fun `paused movie shows focused poster synopsis and resume controls`() { + val (activity, root, controls) = playerSurface() + + controls.findViewById(R.id.player_pause_overlay).visibility = View.VISIBLE + controls.findViewById(R.id.player_now_playing_group).visibility = View.GONE + controls.findViewById(R.id.player_pause_title).text = "The Last Horizon" + controls.findViewById(R.id.player_pause_overview).text = + "A cartographer follows a signal beyond the edge of the known world, " + + "where an abandoned observatory may hold the way home." + controls.findViewById(R.id.player_pause_poster).setImageBitmap(previewArtwork()) + controls.findViewById(androidx.media3.ui.R.id.exo_position).text = "42:18" + controls.findViewById(androidx.media3.ui.R.id.exo_duration).text = "1:54:02" + controls.findViewById(R.id.player_remaining).text = "1h 12m left" + controls.findViewById(R.id.player_finish_time).text = "Ends at 10:14 PM" + + root.captureRoboImage("build/screenshots/player-paused-movie-overlay.png") + } + + @Test + fun `playing OSD keeps video visible behind controls without black container`() { + val (_, root, controls) = playerSurface() + + controls.findViewById(R.id.player_pause_overlay).visibility = View.GONE + controls.findViewById(R.id.player_title_logo).visibility = View.GONE + controls.findViewById(R.id.player_title).apply { + text = "The Last Horizon" + visibility = View.VISIBLE + } + controls.findViewById(androidx.media3.ui.R.id.exo_position).text = "42:18" + controls.findViewById(androidx.media3.ui.R.id.exo_duration).text = "1:54:02" + controls.findViewById(R.id.player_remaining).text = "1h 12m left" + controls.findViewById(R.id.player_finish_time).text = "Ends at 10:14 PM" + + root.captureRoboImage("build/screenshots/player-osd-no-black-container.png") + } + + private fun playerSurface(): Triple { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity) + root.addView( + ImageView(activity).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + setImageBitmap(previewArtwork()) + }, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + val controls = FrameLayout(activity) + root.addView( + controls, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + LayoutInflater.from(activity).inflate(R.layout.memby_player_controls, controls, true) + activity.setContentView(root) + return Triple(activity, root, controls) + } + + private fun previewArtwork() = + javaClass.classLoader + ?.getResourceAsStream("home_hero_preview_art.png") + ?.use(BitmapFactory::decodeStream) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt index db1fd27..f19a502 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlayerTimingTest.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.ui.player import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class PlayerTimingTest { @@ -11,4 +12,50 @@ class PlayerTimingTest { assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L)) assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L)) } + + @Test + fun timeRemainingCueStartsStrictlyBelowTenMinutes() { + assertNull(PlayerActivity.timeRemainingCueMinutes(10L * 60_000L)) + assertEquals(10, PlayerActivity.timeRemainingCueMinutes(10L * 60_000L - 1L)) + assertEquals(2, PlayerActivity.timeRemainingCueMinutes(61_000L)) + assertEquals(1, PlayerActivity.timeRemainingCueMinutes(1L)) + assertNull(PlayerActivity.timeRemainingCueMinutes(0L)) + } + + @Test + fun playbackStartCueWaitsForTenSecondsOfPlaying() { + assertEquals(false, PlayerActivity.playbackStartCueReady(9_999L)) + assertEquals(true, PlayerActivity.playbackStartCueReady(10_000L)) + assertEquals("1 min", PlayerActivity.formatCueDuration(1L)) + assertEquals("42 mins", PlayerActivity.formatCueDuration(42L * 60_000L)) + } + + @Test + fun resumedPlaybackShowsTimeLeftAfterTwoSecondsOfPlaying() { + assertEquals(false, PlayerActivity.playbackStartCueReady(1_999L, resumePositionMs = 1L)) + assertEquals(true, PlayerActivity.playbackStartCueReady(2_000L, resumePositionMs = 1L)) + } + + @Test + fun prerollRuntimeUsesCompactEpisodeFacts() { + assertNull(PlayerActivity.formatPrerollRuntime(0L)) + assertEquals("48 mins", PlayerActivity.formatPrerollRuntime(48L * 60_000L)) + assertEquals("1h 42m", PlayerActivity.formatPrerollRuntime(102L * 60_000L)) + } + + @Test + fun completedPlaybackReturnsHomeUnlessAutoplayCanAdvance() { + assertEquals( + PlaybackCompletionAction.RETURN_HOME, + playbackCompletionAction(hasNextEpisode = false, nextUpDismissed = false), + ) + assertEquals( + PlaybackCompletionAction.RETURN_HOME, + playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = true), + ) + assertEquals( + PlaybackCompletionAction.PLAY_NEXT, + playbackCompletionAction(hasNextEpisode = true, nextUpDismissed = false), + ) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt index 99fb934..292c1c5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt @@ -3,8 +3,11 @@ package com.ponzischeme89.memby.ui.player import android.content.Context import android.view.LayoutInflater import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.ImageView import androidx.test.core.app.ApplicationProvider import com.ponzischeme89.memby.R +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith @@ -20,13 +23,26 @@ class PrerollLayoutTest { val context = ApplicationProvider.getApplicationContext() assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background)) - assertNotNull( - LayoutInflater.from(context).inflate( - R.layout.player_preroll, - FrameLayout(context), - false, - ), + val preroll = LayoutInflater.from(context).inflate( + R.layout.player_preroll, + FrameLayout(context), + false, ) + val videoHost = preroll.findViewById(R.id.player_preroll_video_host) + assertNotNull(videoHost) + assertEquals( + (398 * context.resources.displayMetrics.density).toInt(), + videoHost.layoutParams.width, + ) + assertNotNull(preroll.findViewById(R.id.player_preroll_countdown)) + assertNotNull(preroll.findViewById(R.id.player_preroll_brand)) + assertEquals( + "Starting in 7 seconds…", + context.getString(R.string.player_preroll_countdown_initial), + ) + assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame)) + assertNotNull(preroll.findViewById(R.id.player_preroll_calendar)) + assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS) assertNotNull( LayoutInflater.from(context).inflate( R.layout.player_cast_overlay, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollScreenshotTest.kt new file mode 100644 index 0000000..d2746d8 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollScreenshotTest.kt @@ -0,0 +1,106 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.Activity +import android.graphics.BitmapFactory +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.ImageView +import android.widget.TextView +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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class PrerollScreenshotTest { + @Test + fun `sonarr preroll uses hero video and artwork stack`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity) + val preroll = LayoutInflater.from(activity).inflate(R.layout.player_preroll, root, false) + preroll.visibility = View.VISIBLE + root.addView( + preroll, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + + val artwork = javaClass.classLoader + ?.getResourceAsStream("home_hero_preview_art.png") + ?.use(BitmapFactory::decodeStream) + preroll.findViewById(R.id.player_preroll_video_host).addView( + ImageView(activity).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + setImageBitmap(artwork) + }, + 0, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + + val samples = listOf( + Triple("TODAY · 8:30 PM", "Northbound", "S02E04 · The Crossing"), + Triple("TODAY · 9:00 PM", "Harbour", "S03E01 · Home Water"), + Triple("TODAY · 9:30 PM", "The Bear", "S04E03 · Bridges"), + Triple("TODAY · 10:00 PM", "Slow Horses", "S05E02 · Signals"), + Triple("WED · 7:30 PM", "Foundation", "S03E06 · The Mule"), + Triple("THU · 8:00 PM", "Severance", "S03E01 · Cold Harbour"), + Triple("FRI · 8:30 PM", "Silo", "S03E04 · Legacy"), + Triple("SAT · 7:00 PM", "North Shore", "S01E07 · The Dive"), + ) + preroll.findViewById(R.id.player_preroll_now_title).text = + "Northbound – The Crossing" + preroll.findViewById(R.id.player_preroll_now_metadata).text = + "EPISODE · S02E04 · 48 mins" + preroll.findViewById(R.id.player_preroll_now_overview).text = + "The crew follows a signal beyond the last charted crossing and discovers " + + "a settlement that has been waiting for their arrival." + val calendar = preroll.findViewById(R.id.player_preroll_calendar) + samples.forEachIndexed { index, (label, title, detail) -> + val card = LayoutInflater.from(activity) + .inflate(R.layout.player_preroll_schedule_card, calendar, false) + card.findViewById(R.id.player_preroll_card_artwork).setImageBitmap(artwork) + card.findViewById(R.id.player_preroll_card_label).text = label + card.findViewById(R.id.player_preroll_card_title).text = title + card.findViewById(R.id.player_preroll_card_detail).text = detail + calendar.addView( + card, + GridLayout.LayoutParams().apply { + width = 0 + height = dp(activity, 80) + columnSpec = GridLayout.spec(index % 4, 1f) + rowSpec = GridLayout.spec(index / 4) + setMargins(dp(activity, 4), dp(activity, 3), dp(activity, 4), dp(activity, 3)) + }, + ) + } + val countdown = preroll.findViewById(R.id.player_preroll_countdown) + countdown.setCountdown( + seconds = 7, + progress = 1f, + description = "Starting in 7 seconds", + ) + + activity.setContentView(root) + root.captureRoboImage("build/screenshots/player-sonarr-preroll.png") + + countdown.setCountdown(seconds = 3, progress = 3f / 6.5f, description = "Starting in 3 seconds") + root.captureRoboImage("build/screenshots/player-sonarr-preroll-mid-countdown.png") + } + + private fun dp(activity: Activity, value: Int): Int = + (value * activity.resources.displayMetrics.density).toInt() +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt index d02ec33..bdfb109 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt @@ -6,7 +6,24 @@ import org.junit.Test class PrerollSequenceTest { @Test - fun `handoff waits only for the five second gate`() { + fun `fresh playback shows preroll`() { + assertTrue(shouldShowPreroll(0L)) + } + + @Test + fun `resumed playback skips preroll`() { + assertFalse(shouldShowPreroll(1L)) + assertFalse(shouldShowPreroll(42 * 60_000L)) + } + + @Test + fun `server can disable preroll for fresh playback`() { + assertFalse(shouldShowPreroll(0L, enabled = false)) + assertTrue(shouldShowPreroll(0L, enabled = true)) + } + + @Test + fun `handoff waits for the video countdown gate`() { assertFalse(prerollCanHandOff(true, false, true)) assertTrue(prerollCanHandOff(true, true, true)) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/SeasonFinaleLowerThirdScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/SeasonFinaleLowerThirdScreenshotTest.kt new file mode 100644 index 0000000..2f025c3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/SeasonFinaleLowerThirdScreenshotTest.kt @@ -0,0 +1,53 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.Activity +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +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 + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class SeasonFinaleLowerThirdScreenshotTest { + @Test + fun `season finale stacks above playback start cue`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = FrameLayout(activity).apply { + background = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf( + Color.rgb(17, 37, 48), + Color.rgb(12, 20, 27), + Color.rgb(3, 7, 10), + ), + ) + } + val inflater = LayoutInflater.from(activity) + val timing = inflater.inflate(R.layout.player_time_remaining, root, false).apply { + visibility = View.VISIBLE + findViewById(R.id.player_time_remaining_label).text = "FINISHES IN" + findViewById(R.id.player_time_remaining_value).text = "52 mins (9:54 PM)" + } + val finale = inflater.inflate(R.layout.player_season_finale, root, false).apply { + visibility = View.VISIBLE + findViewById(R.id.player_season_finale_value).text = + "Signal Hill · Season 2 finale" + } + root.addView(timing) + root.addView(finale) + activity.setContentView(root) + + root.captureRoboImage("build/screenshots/player-season-finale-lower-third.png") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/TimeRemainingCueScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/TimeRemainingCueScreenshotTest.kt new file mode 100644 index 0000000..3fed72b --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/TimeRemainingCueScreenshotTest.kt @@ -0,0 +1,80 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.Activity +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +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 + +/** + * Captures the real player XML at TV resolution. The restrained blue-grey field stands + * in for a dark film frame and makes the cue's contrast and content-width easy to judge. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class TimeRemainingCueScreenshotTest { + + @Test + fun `compact time remaining cue over playback`() { + captureCue( + name = "player-time-remaining", + label = "TIME REMAINING", + value = "9 mins", + ) + } + + @Test + fun `compact finishes in cue over playback`() { + captureCue( + name = "player-finishes-in", + label = "FINISHES IN", + value = "42 mins (9:48 PM)", + ) + } + + @Test + fun `compact resumed time left cue over playback`() { + captureCue( + name = "player-resume-time-left", + label = "TIME LEFT", + value = "36 mins", + ) + } + + private fun captureCue(name: String, label: String, value: String) { + 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 cue = LayoutInflater.from(activity).inflate( + R.layout.player_time_remaining, + root, + false, + ) + cue.visibility = View.VISIBLE + cue.findViewById(R.id.player_time_remaining_label).text = label + cue.findViewById(R.id.player_time_remaining_value).text = value + root.addView(cue) + activity.setContentView(root) + + root.captureRoboImage("build/screenshots/$name.png") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchRankingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchRankingTest.kt index e353da4..41efced 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchRankingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchRankingTest.kt @@ -72,4 +72,19 @@ class SearchRankingTest { assertTrue(shouldSearch("ab")) assertTrue(shouldSearch(" the wire ")) } + + @Test + fun `gateway scored order can be preserved by callers`() { + val backendOrder = listOf( + BaseItem(id = "personal", name = "Dune Messiah", membyRecommendationScore = 9.0), + BaseItem(id = "exact", name = "Dune", membyRecommendationScore = 8.0), + ) + // Scored gateway results are intentionally not passed through rankSearchResults. + val displayed = if (backendOrder.any { it.membyRecommendationScore != null }) { + backendOrder + } else { + rankSearchResults("dune", backendOrder) + } + assertEquals(listOf("Dune Messiah", "Dune"), names(displayed)) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/LegalNoticesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/LegalNoticesTest.kt new file mode 100644 index 0000000..7a1c362 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/LegalNoticesTest.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 Memby contributors + * + * SPDX-License-Identifier: GPL-2.0-only + */ + +package com.ponzischeme89.memby.ui.settings + +import com.ponzischeme89.memby.BuildConfig +import org.junit.Assert.assertTrue +import org.junit.Test + +class LegalNoticesTest { + + @Test + fun `distributed app embeds source and complete GPL notice`() { + assertTrue(BuildConfig.SOURCE_CODE_URL.startsWith("https://")) + assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains("Memby")) + assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains(BuildConfig.SOURCE_CODE_URL)) + assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("GNU GENERAL PUBLIC LICENSE")) + assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("Version 2, June 1991")) + assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("END OF TERMS AND CONDITIONS")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt index 010e61d..5abc4ab 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage import com.ponzischeme89.memby.ui.PreviewSurface +import com.ponzischeme89.memby.update.UpdateStatus import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,8 +43,27 @@ class SettingsSheetScreenshotTest { compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png") } + @Test + fun `playback reminder option`() { + compose.setContent { + SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.PLAYBACK) + } + compose.onRoot().captureRoboImage("build/screenshots/settings-playback.png") + } + + @Test + fun `watched movie visibility option`() { + compose.setContent { + SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.HOME) + } + compose.onRoot().captureRoboImage("build/screenshots/settings-home.png") + } + @Composable - private fun SettingsPreviewFixture(overlay: Boolean) { + private fun SettingsPreviewFixture( + overlay: Boolean, + selectedPage: SettingsPage = if (overlay) SettingsPage.UPDATES else SettingsPage.APPEARANCE, + ) { val firstFocus = remember { FocusRequester() } LaunchedEffect(Unit) { firstFocus.requestFocus() } PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) { @@ -51,12 +71,22 @@ class SettingsSheetScreenshotTest { state = SettingsPanelState( showLogo = true, autoPlayNext = false, + showTenMinuteReminder = false, ringColor = "52B54B", homeSections = setOf("continue", "latest"), cardDensity = "standard", showCardMetadata = false, - editableServer = true, - baseUrl = "https://mserver.example/releases/latest.json", + welcomeQuoteStyle = "homicidal", + selectedPage = selectedPage, + updateStatus = if (overlay) { + UpdateStatus.Available( + version = "0.1.61", + apkUrl = "https://example.invalid/memby.apk", + notes = "Faster startup and a better settings rail.", + ) + } else { + null + }, installedVersion = "0.1.60", ), actions = SettingsPanelActions(), diff --git a/app/src/test/java/com/ponzischeme89/memby/update/ServerUpdateServiceTest.kt b/app/src/test/java/com/ponzischeme89/memby/update/ServerUpdateServiceTest.kt new file mode 100644 index 0000000..78b2884 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/update/ServerUpdateServiceTest.kt @@ -0,0 +1,42 @@ +package com.ponzischeme89.memby.update + +import com.ponzischeme89.memby.data.model.GatewayUpdate +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServerUpdateServiceTest { + @Test + fun `returns mandatory server decision without a session dependency`() = runTest { + val service = ServerUpdateService.forTest { + GatewayUpdate( + status = GatewayUpdate.STATUS_MANDATORY, + version = "0.3.0", + downloadUrl = "https://memby.test/updates/memby.apk", + ) + } + + val update = service.check().getOrThrow() + + assertEquals("0.3.0", update?.version) + assertTrue(update?.isMandatory == true) + } + + @Test + fun `non-actionable response is no update`() = runTest { + val service = ServerUpdateService.forTest { + GatewayUpdate(status = GatewayUpdate.STATUS_NONE) + } + + assertNull(service.check().getOrThrow()) + } + + @Test + fun `network failure is reported without manufacturing a blocking update`() = runTest { + val service = ServerUpdateService.forTest { error("offline") } + + assertTrue(service.check().isFailure) + } +} diff --git a/app/src/test/resources/home_hero_preview_art.png b/app/src/test/resources/home_hero_preview_art.png new file mode 100644 index 0000000..0803632 Binary files /dev/null and b/app/src/test/resources/home_hero_preview_art.png differ diff --git a/app/src/test/resources/playback_alert_preview_still.png b/app/src/test/resources/playback_alert_preview_still.png new file mode 100644 index 0000000..2a1c210 Binary files /dev/null and b/app/src/test/resources/playback_alert_preview_still.png differ diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index d2ecccc..b62dd9b 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.test") id("org.jetbrains.kotlin.android") + id("androidx.baselineprofile") } android { @@ -8,12 +9,16 @@ android { compileSdk = 35 defaultConfig { - minSdk = 23 + // Baseline profile generation needs API 28+. The app itself still ships to 23 — + // this floor applies only to the machine generating the profile, not to the TVs + // that consume it. + minSdk = 28 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["androidx.benchmark.enabledRules"] = "Macrobenchmark" - // The target is the current debug build for Phase 1 only. Results are labelled - // debug-influenced; a release benchmark variant will be added before Phase 2. - testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE" + // No DEBUGGABLE suppression any more: the baselineprofile plugin adds + // nonMinifiedRelease / benchmarkRelease variants to :app and mirrors them here, + // so these are real release numbers rather than the debug-influenced ones the + // Phase 1 setup produced. + testInstrumentationRunnerArguments["androidx.benchmark.enabledRules"] = "Macrobenchmark,BaselineProfile" } targetProjectPath = ":app" experimentalProperties["android.experimental.self-instrumenting"] = true @@ -31,6 +36,12 @@ android { } } +baselineProfile { + // A television is the only device that produces a representative profile for this + // app, so generation always runs against whatever is connected over adb. + useConnectedDevices = true +} + kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) @@ -38,7 +49,8 @@ kotlin { } dependencies { - implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4") + implementation("androidx.benchmark:benchmark-macro-junit4:1.3.4") implementation("androidx.test.ext:junit:1.2.1") implementation("androidx.test:runner:1.6.2") + implementation("androidx.test.uiautomator:uiautomator:2.3.0") } diff --git a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/BaselineProfileGenerator.kt new file mode 100644 index 0000000..9236349 --- /dev/null +++ b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/BaselineProfileGenerator.kt @@ -0,0 +1,51 @@ +package com.ponzischeme89.memby.benchmark + +import android.view.KeyEvent +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Records the classes and methods the launcher touches between process start and the + * first few D-pad moves, so ART can compile them ahead of time instead of interpreting + * them on a TV box that has nothing to spare. + * + * Run against a real television: + * + * .\gradlew.bat :app:generateReleaseBaselineProfile + * + * The output lands in app/src/release/generated/baselineProfiles and is checked in, so + * an ordinary `assembleRelease` needs no device. Regenerate it after a change that moves + * the startup path — a stale profile is not harmful, just progressively less useful. + */ +@RunWith(AndroidJUnit4::class) +class BaselineProfileGenerator { + @get:Rule val rule = BaselineProfileRule() + + @Test + fun startupAndBrowse() = rule.collect( + packageName = "com.ponzischeme89.memby", + // The signed-in launcher renders from HomeCache before the network answers, so a + // cold start reaches real rows without waiting on the gateway. Include enough + // D-pad movement to pull in the row/card composables and Coil's decode path, + // which is where the first-scroll jank lives. + includeInStartupProfile = true, + ) { + pressHome() + startActivityAndWait() + browseHome() + } + + private fun MacrobenchmarkScope.browseHome() { + repeat(4) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT) } + device.waitForIdle() + device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN) + repeat(3) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT) } + device.waitForIdle() + device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN) + device.waitForIdle() + } +} diff --git a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt index 5cdce64..1c320e0 100644 --- a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt +++ b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt @@ -15,24 +15,35 @@ import org.junit.runner.RunWith class HomeBenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() + /** + * The baseline for comparison: no ahead-of-time compilation at all. Keep this around + * — the only way to know whether [coldStartToHomeWithProfile] is earning its keep is + * to see the two numbers side by side. + */ @Test - fun coldStartToHome() = benchmarkRule.measureRepeated( - packageName = "com.ponzischeme89.memby", + fun coldStartToHomeNoCompilation() = measureColdStart(CompilationMode.None()) + + /** What a viewer installing a release APK actually gets. */ + @Test + fun coldStartToHomeWithProfile() = measureColdStart(CompilationMode.Partial()) + + private fun measureColdStart(mode: CompilationMode) = benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), - compilationMode = CompilationMode.None(), + compilationMode = mode, startupMode = StartupMode.COLD, - iterations = 3, + iterations = 5, setupBlock = { pressHome() }, measureBlock = { startActivityAndWait() }, ) @Test fun homeDpadAndRows() = benchmarkRule.measureRepeated( - packageName = "com.ponzischeme89.memby", + packageName = PACKAGE_NAME, metrics = listOf(FrameTimingMetric()), - compilationMode = CompilationMode.None(), + compilationMode = CompilationMode.Partial(), startupMode = StartupMode.WARM, - iterations = 3, + iterations = 5, setupBlock = { startActivityAndWait() }, measureBlock = { exerciseHome() }, ) @@ -44,4 +55,8 @@ class HomeBenchmark { device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) device.waitForIdle() } + + private companion object { + const val PACKAGE_NAME = "com.ponzischeme89.memby" + } } diff --git a/build.gradle.kts b/build.gradle.kts index a2db97f..a01fd2b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,7 @@ plugins { id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false + // Generates app/src/release/generated/baselineProfiles from the :benchmark journeys. + // Keep this version in step with the androidx.benchmark artifacts in benchmark/. + id("androidx.baselineprofile") version "1.3.4" apply false } diff --git a/deploy-server.ps1 b/deploy-server.ps1 index 95c5e11..71fc1cf 100644 --- a/deploy-server.ps1 +++ b/deploy-server.ps1 @@ -3,8 +3,10 @@ Deploys the complete Memby Docker Compose stack to MATT-NAS. .DESCRIPTION -Packages the local server build context, Docker Compose file and .env.example, -then streams them to the NAS over one SSH connection. +Builds and verifies a signed Memby APK, packages it with the local server build +context, Docker Compose file and .env.example, then streams everything to the NAS +over one SSH connection. Once the new gateway is healthy it publishes the APK +through the gateway's release endpoint, making it available to older TVs. The remote deployment: - checks Docker and Docker Compose; @@ -28,6 +30,12 @@ This deploys the current local working tree, including uncommitted server change .EXAMPLE .\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180 + +.EXAMPLE +.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates' + +.EXAMPLE +.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m #> #Requires -Version 7.2 @@ -52,20 +60,52 @@ param( [Parameter()] [ValidateRange(30, 600)] - [int] $HealthTimeoutSeconds = 120 + [int] $HealthTimeoutSeconds = 120, + + [Parameter()] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string] $AppVersion, + + [Parameter()] + [string] $ReleaseNotes = '', + + [Parameter()] + [switch] $SkipAppTests, + + [Parameter()] + [switch] $SkipAppRelease + + , + [Parameter()] + [Alias('m')] + [switch] $MandatoryUpdate, + + # PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept + # --m explicitly as the sole positional argument so it can still sit at the end. + [Parameter(Position = 0)] + [ValidateSet('--m')] + [string] $MandatoryFlag ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$mandatoryRelease = $MandatoryUpdate -or $MandatoryFlag -eq '--m' + +if ($mandatoryRelease -and $SkipAppRelease) { + throw '--m cannot be combined with -SkipAppRelease because no update would be published.' +} $script:CurrentStep = 0 -$script:TotalSteps = 5 +$script:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 } function Write-Banner { Write-Host '' Write-Host '╭─ Memby deployment' -ForegroundColor Magenta Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray + if ($mandatoryRelease) { + Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow + } Write-Host '╰─' -ForegroundColor Magenta Write-Host '' } @@ -114,6 +154,91 @@ function Get-RequiredCommand { return $command.Source } +function Invoke-Checked { + param( + [Parameter(Mandatory)] + [string] $FilePath, + + [Parameter()] + [string[]] $Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$FilePath' failed with exit code $LASTEXITCODE." + } +} + +function Get-AndroidSdk { + param([Parameter(Mandatory)][string] $RepositoryDirectory) + + if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) { + return $env:ANDROID_HOME + } + $propertiesPath = Join-Path $RepositoryDirectory 'local.properties' + if (Test-Path -LiteralPath $propertiesPath) { + $sdkLine = Get-Content -LiteralPath $propertiesPath | + Where-Object { $_ -match '^sdk\.dir=' } | + Select-Object -First 1 + if ($sdkLine) { + $sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1). + Replace('\:', ':'). + Replace('\\', '\') + if (Test-Path -LiteralPath $sdkPath) { + return $sdkPath + } + } + } + throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.' +} + +function Import-UserSigningEnvironment { + foreach ($name in @( + 'MEMBY_KEYSTORE', + 'MEMBY_KEYSTORE_PASSWORD', + 'MEMBY_KEY_ALIAS', + 'MEMBY_KEY_PASSWORD' + )) { + $value = [Environment]::GetEnvironmentVariable($name, 'Process') + if ([string]::IsNullOrWhiteSpace($value)) { + $value = [Environment]::GetEnvironmentVariable($name, 'User') + } + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Release signing variable $name is not configured." + } + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) { + throw "Release keystore not found: $env:MEMBY_KEYSTORE" + } +} + +function Get-ConfiguredAppVersion { + param([Parameter(Mandatory)][string] $RepositoryDirectory) + + if ($AppVersion) { + return $AppVersion + } + $gradle = Get-Content -LiteralPath (Join-Path $RepositoryDirectory 'app/build.gradle.kts') -Raw + if ($gradle -notmatch 'val defaultVersionName = "(\d+\.\d+\.\d+)"') { + throw 'Could not read defaultVersionName from app/build.gradle.kts.' + } + return $Matches[1] +} + +function Get-DotEnvValue { + param( + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][string] $Name + ) + + $line = Get-Content -LiteralPath $Path | + Where-Object { $_ -match ("^" + [regex]::Escape($Name) + "=") } | + Select-Object -First 1 + if (-not $line) { return '' } + return $line.Substring($line.IndexOf('=') + 1).Trim() +} + function Assert-SafeRemoteSettings { if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') { throw "RemoteHost contains unsupported characters: '$RemoteHost'." @@ -148,10 +273,24 @@ function New-DeploymentArchive { [string] $RepositoryDirectory, [Parameter(Mandatory)] - [string] $ArchivePath + [string] $ArchivePath, + + [Parameter()] + [string] $ReleaseDirectory ) - & $script:TarCommand -cf $ArchivePath -C $RepositoryDirectory server docker-compose.yml .env.example + $arguments = @( + '-cf', $ArchivePath, + '-C', $RepositoryDirectory, + 'server', 'docker-compose.yml', '.env.example' + ) + if ($ReleaseDirectory) { + $arguments += @( + '-C', (Split-Path -Parent $ReleaseDirectory), + (Split-Path -Leaf $ReleaseDirectory) + ) + } + & $script:TarCommand @arguments if ($LASTEXITCODE -ne 0) { throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)." } @@ -270,11 +409,106 @@ try { Write-Detail 'server/ build context' Write-Detail 'docker-compose.yml' Write-Detail '.env.example' + $releaseDirectory = '' + $releaseVersion = '' + $releaseSHA256 = '' + if (-not $SkipAppRelease) { + foreach ($appPath in @( + (Join-Path $checkoutDirectory 'app/build.gradle.kts'), + (Join-Path $checkoutDirectory 'gradlew.bat') + )) { + if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) { + throw "Required Android build file is missing: $appPath" + } + } + if ([string]::IsNullOrWhiteSpace( + (Get-DotEnvValue -Path (Join-Path $checkoutDirectory '.env.example') ` + -Name 'MEMBY_RELEASE_PUBLISH_TOKEN') + )) { + throw 'MEMBY_RELEASE_PUBLISH_TOKEN is empty in .env.example; a signed APK cannot be published.' + } + } Write-Success 'Deployment payload is complete' Write-Host '' + if (-not $SkipAppRelease) { + Write-Step 'Building and verifying the signed Android update' + Import-UserSigningEnvironment + $releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory + $sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory + $buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 + if (-not $buildTools) { + throw 'Android SDK Build Tools are not installed.' + } + $apkSigner = Join-Path $buildTools.FullName 'apksigner.bat' + if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) { + throw "APK signer not found: $apkSigner" + } + + $jdkHome = 'C:\Program Files\Android\Android Studio\jbr' + if (Test-Path -LiteralPath $jdkHome) { + $env:JAVA_HOME = $jdkHome + } + $gradleArguments = @('--console=plain') + if (-not $SkipAppTests) { + $gradleArguments += 'testDebugUnitTest' + } + $gradleArguments += @( + 'assembleRelease', + "-Pmemby.versionName=$releaseVersion" + ) + Invoke-Checked -FilePath (Join-Path $checkoutDirectory 'gradlew.bat') ` + -Arguments $gradleArguments + + $apk = Join-Path $checkoutDirectory 'app/build/outputs/apk/release/app-release.apk' + if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) { + $unsigned = Join-Path $checkoutDirectory ` + 'app/build/outputs/apk/release/app-release-unsigned.apk' + if (Test-Path -LiteralPath $unsigned -PathType Leaf) { + throw 'Gradle produced an unsigned APK; deployment was stopped.' + } + throw "Signed release APK not found: $apk" + } + Invoke-Checked -FilePath $apkSigner -Arguments @( + 'verify', '--verbose', '--print-certs', $apk + ) + + $metadataPath = Join-Path $checkoutDirectory ` + 'app/build/outputs/apk/release/output-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + $builtVersion = [string]$metadata.elements[0].versionName + if ($builtVersion -ne $releaseVersion) { + throw "Built APK version is $builtVersion, expected $releaseVersion." + } + + $releaseDirectory = Join-Path $workDirectory 'release' + [void] (New-Item -ItemType Directory -Path $releaseDirectory) + $apkName = "memby-$releaseVersion.apk" + Copy-Item -LiteralPath $apk -Destination (Join-Path $releaseDirectory $apkName) + $releaseSHA256 = ( + Get-FileHash -LiteralPath (Join-Path $releaseDirectory $apkName) -Algorithm SHA256 + ).Hash.ToLowerInvariant() + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText( + (Join-Path $releaseDirectory 'version.txt'), $releaseVersion, $utf8NoBom + ) + [System.IO.File]::WriteAllText( + (Join-Path $releaseDirectory 'sha256.txt'), $releaseSHA256, $utf8NoBom + ) + [System.IO.File]::WriteAllText( + (Join-Path $releaseDirectory 'notes.txt'), $ReleaseNotes, $utf8NoBom + ) + Write-Detail "Version $releaseVersion" + Write-Detail "SHA-256 $releaseSHA256" + Write-Success 'Signed Android update is ready' + Write-Host '' + } + Write-Step 'Packaging the release' - New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath + New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath ` + -ReleaseDirectory $releaseDirectory $archiveSize = (Get-Item -LiteralPath $archivePath).Length Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB)) Write-Success 'Release archive is ready' @@ -287,6 +521,8 @@ set -eu destination='__DESTINATION__' health_timeout=__HEALTH_TIMEOUT__ +publish_release=__PUBLISH_RELEASE__ +mandatory_update=__MANDATORY_UPDATE__ parent=$(dirname "$destination") staging="${destination}.new.$$" backup="${destination}.previous.$$" @@ -386,7 +622,7 @@ wait_for_service() { trap rollback EXIT trap 'exit 130' INT TERM -step '[remote 1/8] Checking Docker' +step '[remote 1/9] Checking Docker' if ! command -v docker >/dev/null 2>&1; then failure 'Docker is not installed on the NAS' exit 1 @@ -399,10 +635,14 @@ docker compose version >/dev/null 2>&1 || { failure 'The Docker Compose v2 plugin is not installed' exit 1 } +if [ "$publish_release" -eq 1 ] && ! command -v curl >/dev/null 2>&1; then + failure 'curl is required on the NAS to publish the signed Android update' + exit 1 +fi success "$(docker --version)" success "$(docker compose version)" -step '[remote 2/8] Extracting the release' +step '[remote 2/9] Extracting the release' # Checked before anything is created: the staging directory, the swap and the backup all # need write access to the parent, and "can't create directory" from BusyBox halfway # through a deployment is a poor way to learn the account cannot write there. @@ -424,9 +664,13 @@ mkdir -- "$staging" tar -xf - -C "$staging" test -f "$staging/docker-compose.yml" test -f "$staging/server/Dockerfile" +if [ "$publish_release" -eq 1 ]; then + test -f "$staging/release/version.txt" + test -f "$staging/release/sha256.txt" +fi success 'Release extracted' -step '[remote 3/8] Installing configuration from .env.example' +step '[remote 3/9] Installing configuration from .env.example' # The local .env.example is the single source of truth for configuration and carries # real values rather than placeholders. Every deployment overwrites the deployed .env # with it, so neither a Git push nor an SSH edit is needed. @@ -445,6 +689,7 @@ new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | t admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r') emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r') configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r') +release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r') if [ -z "$new_password" ]; then failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start' exit 1 @@ -461,6 +706,10 @@ if [ "$configured_port" != '32768' ]; then failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})" exit 1 fi +if [ "$publish_release" -eq 1 ] && [ -z "$release_token" ]; then + failure 'MEMBY_RELEASE_PUBLISH_TOKEN is empty; the signed APK cannot be published' + exit 1 +fi success 'Required gateway configuration is present' if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then failure 'POSTGRES_PASSWORD differs from the deployed value' @@ -475,21 +724,21 @@ fi ) success 'Compose configuration is valid' -step '[remote 4/8] Pulling PostgreSQL and Redis' +step '[remote 4/9] Pulling PostgreSQL and Redis' ( cd "$staging" docker compose pull postgres redis ) success 'Dependency images are ready' -step '[remote 5/8] Building memby-server' +step '[remote 5/9] Building memby-server' ( cd "$staging" docker compose build --pull server ) success 'Server image built' -step '[remote 6/8] Activating the release' +step '[remote 6/9] Activating the release' rm -rf -- "$backup" if [ -e "$destination" ] || [ -L "$destination" ]; then if [ -f "$destination/docker-compose.yml" ]; then @@ -511,12 +760,12 @@ mv -- "$staging" "$destination" activated=1 success 'Release activated' -step '[remote 7/8] Starting the Compose stack' +step '[remote 7/9] Starting the Compose stack' cd "$destination" docker compose up -d --remove-orphans success 'Compose start command completed' -step '[remote 8/8] Waiting for healthy services' +step '[remote 8/9] Waiting for healthy services' wait_for_service postgres wait_for_service redis wait_for_service server @@ -536,6 +785,52 @@ if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$serve fi success 'Runtime configuration includes the admin token' +step '[remote 9/9] Publishing the signed Android update' +if [ "$publish_release" -eq 1 ]; then + release_version=$(tr -d '\r\n' < "$destination/release/version.txt") + release_sha256=$(tr -d '\r\n' < "$destination/release/sha256.txt") + if ! printf '%s' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + failure 'The packaged Android version is invalid' + exit 1 + fi + if ! printf '%s' "$release_sha256" | grep -Eq '^[0-9a-f]{64}$'; then + failure 'The packaged Android checksum is invalid' + exit 1 + fi + release_apk="$destination/release/memby-${release_version}.apk" + if [ ! -f "$release_apk" ]; then + failure "Signed APK is missing: $release_apk" + exit 1 + fi + + release_response="$destination/release/publish-response.json" + release_status=$(curl --show-error --silent \ + --output "$release_response" \ + --write-out '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $release_token" \ + -F "version=$release_version" \ + -F "sha256=$release_sha256" \ + -F "mandatory=$mandatory_update" \ + -F "notes=<$destination/release/notes.txt" \ + -F "apk=@$release_apk;type=application/vnd.android.package-archive" \ + http://127.0.0.1:32768/admin/api/release) + case "$release_status" in + 2??) ;; + *) + failure "Gateway rejected the Android update (HTTP ${release_status:-unknown})" + if [ -s "$release_response" ]; then + detail "$(tr -d '\r\n' < "$release_response")" + fi + exit 1 + ;; + esac + rm -rf -- "$destination/release" + success "Memby $release_version is available to older TVs" +else + detail 'Skipped by -SkipAppRelease' +fi + printf '\n' docker compose ps printf '\n' @@ -549,6 +844,14 @@ success 'Memby gateway: https://mserver.sublogue.com' $remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination) $remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString()) + $remoteCommand = $remoteCommand.Replace( + '__PUBLISH_RELEASE__', + $(if ($SkipAppRelease) { '0' } else { '1' }) + ) + $remoteCommand = $remoteCommand.Replace( + '__MANDATORY_UPDATE__', + $(if ($mandatoryRelease) { '1' } else { '0' }) + ) # This file is edited on Windows, so the here-string above arrives with whatever line # endings it was saved with. A remote shell reads a trailing carriage return as part # of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the @@ -567,6 +870,9 @@ success 'Memby gateway: https://mserver.sublogue.com' Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray + if (-not $SkipAppRelease) { + Write-Host " TV update: Memby $releaseVersion (signed and published)" -ForegroundColor White + } } catch { $deploymentTimer.Stop() diff --git a/dist/template/index.html b/dist/template/index.html index 056eb08..b5f0595 100644 --- a/dist/template/index.html +++ b/dist/template/index.html @@ -95,6 +95,19 @@

+
+

Free and open-source software

+

+ Memby is licensed under the GNU General Public License v2. The corresponding source + for this build is available at: +

+ {{SOURCE_URL}} +

+ The complete licence and acknowledgements + are included alongside this download and inside the app under Settings. +

+
+
Memby · by ponzischeme89
diff --git a/docker-compose.yml b/docker-compose.yml index 67167ef..8c84180 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,9 @@ services: # INFO keeps Docker logs concise. Temporarily use DEBUG to include successful # health checks, maintenance polling and artwork requests. MEMBY_LOG_LEVEL: "${MEMBY_LOG_LEVEL:-INFO}" - # Local day boundaries and schedule labels for Sonarr's "airing today" row. + MEMBY_LOG_BUFFER_CAPACITY: "${MEMBY_LOG_BUFFER_CAPACITY:-5000}" + GOMEMLIMIT: "${MEMBY_GOMEMLIMIT:-384MiB}" + # Local day boundaries and labels for Sonarr and Radarr schedule rows. MEMBY_TIMEZONE: "${MEMBY_TIMEZONE:-Pacific/Auckland}" # How the gateway reaches Emby. MEMBY_EMBY_URL: "${MEMBY_EMBY_URL:?set MEMBY_EMBY_URL in .env}" @@ -24,9 +26,9 @@ services: MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable" MEMBY_REDIS_URL: "redis://redis:6379/0" MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}" + MEMBY_RECOMMEND_TTL: "${MEMBY_RECOMMEND_TTL:-24h}" # Strict per-Emby-user TV allowance. Signing the same physical TV in again # replaces its token and does not consume another slot. - MEMBY_MAX_CLIENTS_PER_USER: "${MEMBY_MAX_CLIENTS_PER_USER:-1}" # Unset disables /admin entirely — the library import, maintenance switch and # analytics page all live behind it. MEMBY_ADMIN_TOKEN: "${MEMBY_ADMIN_TOKEN:-}" @@ -46,6 +48,11 @@ services: MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}" MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}" MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}" + # Optional read-only Radarr calendar integration. Only digital release dates + # appear in the five-day movie row. + MEMBY_RADARR_URL: "${MEMBY_RADARR_URL:-}" + MEMBY_RADARR_API_KEY: "${MEMBY_RADARR_API_KEY:-}" + MEMBY_RADARR_TTL: "${MEMBY_RADARR_TTL:-5m}" # Optional Tracearr public API. Memby reads recent playback analytics to rank # the dedicated For You area; the token never leaves this container. MEMBY_TRACEARR_URL: "${MEMBY_TRACEARR_URL:-}" @@ -53,8 +60,10 @@ services: MEMBY_TRACEARR_SERVER_ID: "${MEMBY_TRACEARR_SERVER_ID:-}" MEMBY_TRACEARR_SYNC_INTERVAL: "${MEMBY_TRACEARR_SYNC_INTERVAL:-5m}" MEMBY_TRACEARR_FULL_INTERVAL: "${MEMBY_TRACEARR_FULL_INTERVAL:-24h}" - MEMBY_FOR_YOU_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-10m}" - MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-30m}" + MEMBY_FOR_YOU_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-24h}" + MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-24h}" + MEMBY_FOR_YOU_REBUILD_HOUR: "${MEMBY_FOR_YOU_REBUILD_HOUR:-4}" + mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}" volumes: - memby-releases:/data/releases depends_on: diff --git a/release.ps1 b/release.ps1 index a784195..4f827bf 100644 --- a/release.ps1 +++ b/release.ps1 @@ -8,6 +8,7 @@ index.html landing page people are sent to latest.json update manifest the app polls memby-.apk the build itself + LICENSE / NOTICE licence terms and acknowledgements Copy that folder to whatever the NAS serves, keeping the file names. Old APKs can stay alongside — only latest.json decides what the app offers. @@ -23,13 +24,18 @@ Public URL of the folder on the NAS, e.g. https://nas.example.com/memby. Used to build absolute links on the landing page. +.PARAMETER SourceUrl + Recipient-accessible URL for the exact corresponding source. This link is embedded + in the APK and release page. It must remain available to everyone receiving a build. + .EXAMPLE .\release.ps1 -Version 0.1.54 -Notes "Faster home screen" -BaseUrl https://nas.example.com/memby #> param( [string] $Version, [string] $Notes = '', - [Parameter(Mandatory = $true)][string] $BaseUrl + [Parameter(Mandatory = $true)][string] $BaseUrl, + [string] $SourceUrl = 'https://g.sublogue.com/admin/memby' ) $ErrorActionPreference = 'Stop' @@ -65,7 +71,7 @@ if ($Version) { # --- build ----------------------------------------------------------------- -& (Join-Path $root 'gradlew.bat') --console=plain clean test assembleRelease +& (Join-Path $root 'gradlew.bat') --console=plain clean test assembleRelease "-Pmemby.sourceUrl=$SourceUrl" if ($LASTEXITCODE -ne 0) { throw 'Build failed' } $apk = Join-Path $root 'app\build\outputs\apk\release\app-release.apk' @@ -88,6 +94,10 @@ if (Test-Path $outDir) { Remove-Item $outDir -Recurse -Force } New-Item -ItemType Directory -Force $outDir | Out-Null Copy-Item $apk (Join-Path $outDir $apkName) +Copy-Item (Join-Path $root 'LICENSE') (Join-Path $outDir 'LICENSE') +$notice = (Get-Content -Raw (Join-Path $root 'NOTICE')). + Replace('https://g.sublogue.com/admin/memby', $SourceUrl) +Set-Content -LiteralPath (Join-Path $outDir 'NOTICE') -Value $notice -Encoding utf8 # apkUrl stays relative so the folder keeps working if the NAS is reached by another # name; the app resolves it against the manifest's own URL. @@ -104,6 +114,7 @@ $page = $page.Replace('{{VERSION}}', $Version). Replace('{{APK_NAME}}', $apkName). Replace('{{APK_URL}}', "$base/$apkName"). Replace('{{BASE_URL}}', $base). + Replace('{{SOURCE_URL}}', $SourceUrl). Replace('{{SIZE}}', $size). Replace('{{NOTES}}', $(if ($Notes) { $Notes } else { 'Various improvements.' })). Replace('{{DATE}}', (Get-Date -Format 'd MMMM yyyy')) diff --git a/server/README.md b/server/README.md index f63b846..01a1ad0 100644 --- a/server/README.md +++ b/server/README.md @@ -75,10 +75,13 @@ headers attached. | Method | Path | Purpose | | --- | --- | --- | | POST | `/v1/auth/login` | Emby credentials + device identity in, gateway token out | -| GET | `/v1/auth/policy` | Public client allowance used by the sign-in screen | | POST | `/v1/auth/logout` | Retire this device's token | | GET | `/v1/auth/session` | Confirm a stored token is still valid | +| GET | `/v1/auth/devices` | List this user's signed-in TVs | +| PUT | `/v1/auth/devices/{deviceId}` | Rename a signed-in TV | +| DELETE | `/v1/auth/devices/{deviceId}` | Revoke another signed-in TV | | GET | `/v1/status` | Lightweight live maintenance state plus informational alerts; remains available during maintenance | +| GET | `/v1/features` | Versioned, capability-gated feature document evaluated for this TV | | GET | `/v1/home?limit=` | **Every launcher row in one response** | | GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild | | GET | `/v1/for-you?minutes=` | Tracearr-powered, explainable picks for a 0/30/60/120-minute viewing window | @@ -109,7 +112,8 @@ three fixed rows repeated flat for the client's offline cache: "rows": [ {"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]}, {"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]}, - {"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]}, + {"id": "for-you:pick-up", "title": "Pick this show up again", "kind": "for-you", "items": [...]}, + {"id": "sonarr-airing-today", "title": "Shows airing in the next 5 days", "kind": "schedule", "items": [...]}, {"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]}, {"id": "latest-movies", "title": "Recent New Releases", "kind": "latest", "items": [...]}, {"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]}, @@ -149,12 +153,10 @@ Rows shorter than four items are dropped, and a user with no history gets no row rather than a strip of noise, except curated shelves: these remain useful with their quality-ranked fallback. -**The home screen never waits on the engine.** Rows live in their own `r::rows:v2` -cache key with a long TTL (2h). A cache miss serves home immediately without them and +**The home screen never waits on the engine.** Rows live in their own `r::rows:v3` +cache key with a long TTL (24h). A cache miss serves home immediately without them and triggers a background rebuild — deduplicated per user, so four TVs waking together do the -work once. Because the key sits outside the `u:` namespace, a favourite toggle does not -throw the recommendations away; only a finished playback does, since that is the one event -that genuinely changes viewing history. +work once. Playback and favourite mutations do not discard this slow-moving taste cache. The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against a fake Emby (`engine_test.go`), so neither needs a server to verify. @@ -184,7 +186,7 @@ after two unchanged pages (at most ten); a daily full pass catches late or out-o updates and reconciles deletions. Imports are idempotent. Postgres retains the compact source sessions, one derived profile per enabled Emby user, -and every eligible ranked Movie/Series candidate. The background builder reads Tracearr's +and at most 750 ranked Movie/Series candidates per user. The background builder reads Tracearr's public users endpoint and exact-matches usernames against Emby's user list. Unmatched Emby users still receive Emby-history recommendations; similar-looking names are never fuzzily joined without an explicit operator decision. @@ -197,15 +199,24 @@ there is enough playback evidence. Completed-title evidence is distributed deterministically across relevant candidates rather than allowing the newest Drama title to explain every Drama recommendation. -The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Pools may be -30 minutes stale; rebuilds inside a ten-minute window are coalesced. A cold or failed -rebuild uses the original live Tracearr/Emby path, so persistence cannot blank the area. +The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr imports +stay frequent but do not rebuild pools. A session's first transition to stopped/completed +dirties only its matched user, and all pools rebuild once daily at the configured local +off-peak hour. A stored algorithm version triggers a one-time startup migration only when +ranking behavior changes. A cold or failed rebuild uses the original live Tracearr/Emby +path, so persistence cannot blank the area. Every returned item is enriched with `MembyRecommendationReason` and `MembyCompatibility`. The TV shows the reason on the card and in the focused metadata panel. Missing Tracearr or sparse codec evidence degrades to Emby/Memby signals rather than blanking the area; Tracearr errors never affect the essential home rows. +Tracearr episode history also identifies shows abandoned in season one. A show qualifies +after 21 days without activity only when Emby still returns a season-one `Next Up` +episode, which prevents completed shows and later-season pauses from leaking into the +row. Genuine matches appear on Home as **Pick this show up again**, even when only one +show qualifies; generic recommendations are never used as padding. + Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android client already models this shape, so there is no second schema to keep in sync. `app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope @@ -214,9 +225,12 @@ around it from both sides. ### Sonarr schedule When `MEMBY_SONARR_URL` and `MEMBY_SONARR_API_KEY` are set, the gateway reads Sonarr's -v3 calendar and inserts **Shows airing today** directly after Next Up. Cards show the -local air time, season/episode number, episode title and one of: upcoming, downloading, -awaiting download, unmonitored, or the exact time Sonarr added the episode file. +v3 calendar and inserts **Shows airing in the next 5 days** near Continue Watching. +The window is today plus the following four local calendar days. Card timing reads +`In 8 hours (4:00 PM)` for later today, `Tomorrow: 4:00 PM` for tomorrow, and then the +weekday and local time. Cards also show season/episode number, episode title and one of: +upcoming, downloading, awaiting download, unmonitored, or the exact time Sonarr added +the episode file. This row is informational. An episode listed before it is downloaded is not an Emby item, so the TV deliberately does not offer Play, Favourite or Watched actions on it. @@ -252,28 +266,56 @@ rather than once: a TV that was showing its screensaver when the episode aired w the alert up when someone comes back to it, and one that never does simply lets the alert expire with the window. `MEMBY_SONARR_ALERT_WINDOW` bounds how long after air time an alert stays current; -`0` disables banners while leaving the airing-today row alone. +`0` disables banners while leaving the five-day schedule row alone. + +### Radarr digital-release schedule + +When `MEMBY_RADARR_URL` and `MEMBY_RADARR_API_KEY` are set, the gateway reads Radarr's +v3 calendar and inserts **Upcoming Movie releases** beside the Sonarr schedule near +Continue Watching. The row covers today plus the following four local calendar days. +Radarr's `digitalRelease` date is preferred. When it is missing, the row uses an +estimated digital availability date 30 days after `inCinemas`; physical-disc dates are +ignored. A known digital date always wins, including for older films with later cinema +re-releases. + +Movie posters and fanart are proxied through the gateway so the Radarr API key never +leaves the server. `MEMBY_RADARR_TTL` controls the shared calendar cache lifetime. ## Admin interface `https://mserver.sublogue.com/admin/` — a single self-contained page for library imports, the maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every -`/admin` route 404s so it cannot be left exposed by accident. Loading the page establishes -a persistent HttpOnly admin cookie automatically, so the configured token survives server -and browser reboots without being pasted into the page. Anyone who can load this local-only -page is therefore an admin: put the whole path behind your reverse proxy's own auth before -making the gateway reachable outside the LAN. +`/admin` route 404s so it cannot be left exposed by accident. The page first uses the same +discreet Emby login gate as the private installer. After successful verification it +establishes the HttpOnly admin cookie, but browser API requests require both that cookie +and the current 30-minute Emby-verified session. The old admin cookie therefore cannot +bypass the gate after the browser session expires. Scripts may continue to use +`Authorization: Bearer ` without a browser session. | Method | Path | Purpose | | --- | --- | --- | | GET | `/admin/` | The page | | GET | `/admin/api/status` | Library counts, sync history, maintenance state | +| GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics | | POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` | | POST | `/admin/api/for-you` | `{"action":"incremental-import"}`, `{"action":"full-import"}`, or `{"action":"rebuild-all"}` | | POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` | | POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` | +| POST | `/admin/api/features` | Publish feature overrides, enter safe mode, reset defaults, or roll back one revision | | GET | `/admin/api/analytics?days=7` | Row engagement | +The **Features** admin page is the recovery surface for optional TV behaviour. Flags are +registered in the server catalogue and persist only explicit overrides; clearing an +override returns it to its code-owned safe default. Every write is revision-checked and +retains the prior revision. Safe mode disables all optional flags while leaving sign-in, +browsing, playback, maintenance and the admin console operational. + +TVs report `X-Memby-Version`, `X-Memby-Protocol`, and a bounded +`X-Memby-Capabilities` list on every request. Protocol and capability checks fail closed, +so a server never advertises an optional contract to a client that did not declare it. +The first migrated controls are the Sonarr preroll, automatic My Shows, and its automatic +follow notification; all three can be stopped without publishing an APK. + ## Library import `internal/library` copies Emby's catalogue into Postgres so the gateway answers from its @@ -319,7 +361,41 @@ directly in the database works too. The gateway decides whether a TV may keep running its current build. Clients send `X-Memby-Version` on every request and ask `GET /v1/update` on each launch and hourly -while left open; the verdict is `none`, `optional` or `mandatory`. +while left open; the verdict is `none`, `optional` or `mandatory`. This endpoint is +deliberately public and remains available during maintenance: update policy is checked +before login and never reads, validates, or mutates a viewer session. + +### First-time TV installation + +The gateway hosts a public bootstrap page at: + +```text +https://mserver.sublogue.com/install +``` + +The site root displays the same page. Before any release details or download link are +shown, it asks for an Emby household username and password and verifies them directly +against Emby. Credentials are never stored. The temporary Emby access token is logged +out immediately, and the browser receives a secure, HTTP-only, same-site installer cookie +valid for 30 minutes. This browser login does not create a Memby TV session and therefore +does not appear in the user's signed-in device list. + +After authentication, `/updates/latest.apk` redirects to the current immutable, versioned +APK in the persistent `memby-releases` volume. Direct APK requests require either that +short-lived browser session or the per-release signed URL returned to an authenticated +Memby app during its update check; guessed filenames return 404. This is the exact signed +artefact produced and published by `deploy-server.ps1`, not a separate bootstrap build. +The APK is compiled with `memby.gatewayUrl=https://mserver.sublogue.com`, so a fresh +installation opens directly at Memby's login screen. + +On a new TV, enter the short `/install` address in a browser or the Downloader app, +download the APK, allow that browser/downloader to install unknown apps if Android asks, +then install and launch Memby. No Files/Storage permission is needed by Memby itself. + +The installer is also intentionally absent from search: `/robots.txt` disallows the complete +host, the page and APK responses send `X-Robots-Tag` no-index/no-follow/no-archive +directives, and the HTML repeats those controls for major crawlers. A scanner can discover +that the host exists, but it cannot see a release or download an APK without valid access. ### Automated tagged releases @@ -353,6 +429,39 @@ git push origin main v0.1.54 Tags are the release boundary; an ordinary branch push never publishes an APK. Android will only accept updates signed by the same keystore as the installed app. +### Publishing an update with `deploy-server.ps1` + +The normal NAS deployment can build and publish the TV app in the same operation: + +```powershell +.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates' +``` + +Append `--m` to make the published update mandatory. Older clients receive a blocking +update screen with no skip or dismiss action: + +```powershell +.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m +``` + +It loads the same user-level `MEMBY_KEYSTORE*` variables as `deploy-tv.ps1`, runs the +Android tests, builds the requested release without editing `build.gradle.kts`, verifies +the APK signature, and sends the signed artefact in the SSH deployment archive. After +PostgreSQL, Redis and the replacement gateway are healthy, the remote script publishes +the APK through `POST /admin/api/release`. The gateway records its SHA-256 digest and +byte size in PostgreSQL and serves it from the persistent `memby-releases` volume. + +`MEMBY_RELEASE_PUBLISH_TOKEN` must contain a stable, private value in `.env.example`. +Every update intended for installed TVs needs a version higher than the one they already +run; Android rejects equal or lower `versionCode` values. Reusing a version for different +APK bytes is also rejected because release URLs are immutable. + +For a server-only emergency deployment, explicitly opt out: + +```powershell +.\deploy-server.ps1 -SkipAppRelease +``` + Set it on the admin page: **latest version**, **APK URL** (normally the same file the landing page serves), release notes, and a **Require this update** toggle. @@ -405,8 +514,8 @@ Postgres is the durable half: it holds gateway sessions, the imported Emby catal Tracearr's compact recommendation history, derived profiles and prepared candidate pools. Losing Redis costs a cold cache; losing Postgres signs everyone out and requires catalogue and recommendation backfills. A gateway session is unique per Emby user and stable device -ID; signing the same TV in again rotates its token, while a new TV is rejected once -`MEMBY_MAX_CLIENTS_PER_USER` is reached. +ID; signing the same TV in again rotates its token. Accounts have no device cap; viewers +can review and revoke signed-in TVs from the app's Settings screen. ## Configuration @@ -418,17 +527,21 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o | `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | | | `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | | | `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests | +| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded in-memory admin event ring; `0` disables capture | +| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` | +| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container | | `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows | | `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list | | `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` | -| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm | +| `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm | | `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild | | `MEMBY_TRACEARR_URL` / `MEMBY_TRACEARR_API_KEY` | *empty* | Read-only Tracearr public API; both values are required | | `MEMBY_TRACEARR_SERVER_ID` | *empty* | Optional server scope when Tracearr monitors several servers | | `MEMBY_TRACEARR_SYNC_INTERVAL` | `5m` | Incremental session import cadence; `0` disables scheduling | | `MEMBY_TRACEARR_FULL_INTERVAL` | `24h` | Full reconciliation cadence | -| `MEMBY_FOR_YOU_MIN_REBUILD_AGE` | `10m` | Coalesces bursts of pool invalidations | -| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `30m` | Acceptable prepared-pool staleness | +| `MEMBY_FOR_YOU_MIN_REBUILD_AGE` | `24h` | Safety bound for non-forced pool rebuilds | +| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `24h` | Acceptable prepared-pool staleness | +| `MEMBY_FOR_YOU_REBUILD_HOUR` | `4` | Local hour (0–23) for the daily prepared rebuild | | `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled | | `MEMBY_PUBLIC_URL` | *empty* | Public gateway origin used for APK download links | | `MEMBY_RELEASE_DIR` | `/data/releases` | Persistent signed APK directory | @@ -440,11 +553,13 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o | `MEMBY_SONARR_API_KEY` | *empty* | Sonarr Settings → General → Security API key | | `MEMBY_SONARR_TTL` | `5m` | Shared Redis lifetime for today's calendar | | `MEMBY_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners | +| `MEMBY_RADARR_URL` | *empty* | Radarr address reachable by the gateway; empty disables integration | +| `MEMBY_RADARR_API_KEY` | *empty* | Radarr Settings → General → Security API key | +| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the five-day digital-release calendar | | `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports | | `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this | | `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis | | `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h | -| `MEMBY_MAX_CLIENTS_PER_USER` | `1` | Strict maximum number of distinct signed-in TVs per Emby user | | `MEMBY_UPSTREAM_TIMEOUT` | `20s` | | ## Security notes diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index be51eec..783a77d 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -4,7 +4,7 @@ package main import ( "context" - "encoding/hex" + "encoding/json" "errors" "flag" "fmt" @@ -23,6 +23,7 @@ import ( "github.com/ponzischeme89/memby/server/internal/foryou" "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/logging" + "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" @@ -43,7 +44,8 @@ func main() { } logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL")) - log, events := logging.NewBuffered(os.Stdout, logLevel, 20_000) + logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000) + log, events := logging.NewBuffered(os.Stdout, logLevel, logCapacity) if err := run(log, events); err != nil { log.Error("fatal", "error", err) @@ -88,36 +90,21 @@ func run(log *slog.Logger, events *logging.Buffer) error { } embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout) - retiredSessions, err := st.TrimSessionsToLimit(ctx, cfg.MaxClientsPerUser) - if err != nil { - return err - } - for _, sess := range retiredSessions { - _ = ca.Delete(ctx, cache.SessionKey(hex.EncodeToString(sess.TokenHash))) - if err := embyClient.Logout(ctx, emby.Credentials{ - UserID: sess.EmbyUserID, Token: sess.EmbyToken, - DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, - }); err != nil { - log.Warn("could not retire excess emby session", - "username", sess.Username, - "device", sess.DeviceName, - "error", err, - ) - } - } - if len(retiredSessions) > 0 { - log.Info("enforced per-user device allowance", - "retired_sessions", len(retiredSessions), - "max_clients_per_user", cfg.MaxClientsPerUser, - ) - } var sonarrClient *sonarr.Client if cfg.SonarrURL != "" { sonarrClient = sonarr.New(cfg.SonarrURL, cfg.SonarrAPIKey, cfg.UpstreamTimeout) log.Info("sonarr integration enabled", "url", cfg.SonarrURL) } + var radarrClient *radarr.Client + if cfg.RadarrURL != "" { + radarrClient = radarr.New(cfg.RadarrURL, cfg.RadarrAPIKey, cfg.UpstreamTimeout) + log.Info("radarr integration enabled", "url", cfg.RadarrURL) + } recommender := recommend.NewEngine(embyClient, log) + if cfg.RecommendationWeights != "" { + _ = json.Unmarshal([]byte(cfg.RecommendationWeights), &recommender.WeightedConfig) + } // Candidates come from the imported library when one exists, which keeps the // recommendation rebuild off Emby entirely. recommender.Library = st @@ -145,18 +132,11 @@ func run(log *slog.Logger, events *logging.Buffer) error { st, tracearrClient, recommender, log, cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval, ) + forYouService.ConfigureTimeContext(cfg.SonarrLocation) forYouService.ConfigureHouseholdUsers(embyClient, emby.Credentials{ UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey, DeviceID: "memby-for-you-builder", DeviceName: "Memby For You builder", }) - syncer.SetAfterSync(func() { - go func() { - rebuildCtx, cancel := context.WithTimeout(context.Background(), cfg.RecommendTimeout) - defer cancel() - forYouService.MarkAllDirty(rebuildCtx) - _ = forYouService.RebuildAll(rebuildCtx, false) - }() - }) } server := api.New(cfg, api.Deps{ @@ -166,15 +146,31 @@ func run(log *slog.Logger, events *logging.Buffer) error { Recommender: recommender, ForYou: forYouService, Sonarr: sonarrClient, + Radarr: radarrClient, Syncer: syncer, Log: log, Events: events, }) + // Installed after the server exists, because both halves of a finished import are + // its business: derived data has to be invalidated, and the TVs told the catalogue + // moved. Kept off the syncer itself so library stays ignorant of the API. + syncer.SetAfterSync(func(result library.Result) { + afterCtx, cancel := context.WithTimeout(context.Background(), cfg.RecommendTimeout) + defer cancel() + server.AnnounceLibrarySync(afterCtx, result) + if forYouService == nil || (result.Changed == 0 && result.Removed == 0) { + return + } + forYouService.MarkAllDirty(afterCtx) + }) + if err := server.LoadMaintenance(ctx); err != nil { return err } go server.WatchMaintenance(ctx, 30*time.Second) + // One probe per gateway, not per TV: the answer is the same for the whole house. + go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval) if err := server.LoadUpdatePolicy(ctx); err != nil { return err @@ -194,17 +190,22 @@ func run(log *slog.Logger, events *logging.Buffer) error { ctx, cfg.TracearrSyncInterval, cfg.TracearrFullInterval, - cfg.ForYouRefreshInterval, + cfg.ForYouRebuildHour, ) go func() { importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout) - defer cancel() if _, err := forYouService.Import(importCtx, false); err != nil { + cancel() log.Warn("startup Tracearr import failed", "error", err) return } - if err := forYouService.RebuildAll(importCtx, false); err != nil { - log.Warn("startup For You rebuild failed", "error", err) + cancel() + // Only an algorithm-version change warrants startup work. Normal dirty + // profiles wait for the daily off-peak rebuild. + rebuildCtx, rebuildCancel := context.WithTimeout(ctx, cfg.SyncTimeout) + defer rebuildCancel() + if err := forYouService.RebuildOutdated(rebuildCtx); err != nil { + log.Warn("startup outdated For You rebuild failed", "error", err) } }() } diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 7610f19..2187712 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -6,6 +6,9 @@ import ( _ "embed" "encoding/json" "net/http" + "os" + "runtime" + "runtime/debug" "strconv" "strings" "time" @@ -26,19 +29,68 @@ const adminCookieName = "memby_admin" func (s *Server) adminRoutes() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /admin/{$}", s.handleAdminPage) + mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot) + mux.HandleFunc("GET /admin/{page}", s.handleAdminPage) mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus)) + mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations)) mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents)) + mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) + mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy)) + mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy)) + mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy)) mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish)) return mux } +var adminPages = map[string]bool{ + "library": true, "recommendations": true, "requests": true, + "features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true, + "imports": true, "logs": true, +} + +func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) { + if s.cfg.AdminToken == "" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/admin/features", http.StatusFound) +} + +type adminRuntimeStatus struct { + Goroutines int `json:"goroutines"` + GOMAXPROCS int `json:"gomaxprocs"` + HeapAlloc uint64 `json:"heapAlloc"` + HeapInuse uint64 `json:"heapInuse"` + HeapIdle uint64 `json:"heapIdle"` + HeapReleased uint64 `json:"heapReleased"` + StackInuse uint64 `json:"stackInuse"` + Sys uint64 `json:"sys"` + NextGC uint64 `json:"nextGc"` + NumGC uint32 `json:"numGc"` + MemoryLimit int64 `json:"memoryLimit"` + ConfiguredLim string `json:"configuredLimit,omitempty"` +} + +func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) { + var memory runtime.MemStats + runtime.ReadMemStats(&memory) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, adminRuntimeStatus{ + Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0), + HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse, + HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased, + StackInuse: memory.StackInuse, Sys: memory.Sys, + NextGC: memory.NextGC, NumGC: memory.NumGC, + MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"), + }) +} + func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) { if s.events == nil { writeJSON(w, http.StatusOK, map[string]any{ @@ -52,22 +104,25 @@ func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.events.Events(after, limit)) } -// adminAuth guards the admin API with the shared token. Browser requests use the -// persistent HttpOnly cookie established by the admin page; automation can continue to -// send the token as a Bearer header. +// adminAuth guards the admin API with the shared token. Browser requests need both the +// admin cookie and a current Emby-verified browser session. Automation can continue to +// send the admin token as a Bearer header without pretending to be a browser. func (s *Server) adminAuth(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.cfg.AdminToken == "" { http.NotFound(w, r) return } - presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if presented == "" { + authorization := strings.TrimSpace(r.Header.Get("Authorization")) + presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + browser := presented == "" + if browser { if cookie, err := r.Cookie(adminCookieName); err == nil { presented = cookie.Value } } - if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 { + if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 || + (browser && !s.validInstallerSession(r)) { writeError(w, http.StatusUnauthorized, "invalid admin token") return } @@ -80,6 +135,15 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + page := strings.TrimSpace(r.PathValue("page")) + if !adminPages[page] { + http.NotFound(w, r) + return + } + if !s.validInstallerSession(r) { + s.renderAccessLogin(w, r, "", http.StatusOK, "/admin/"+page) + return + } secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") http.SetCookie(w, &http.Cookie{ Name: adminCookieName, @@ -92,18 +156,26 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { }) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") + preventDiscovery(w) _, _ = w.Write(adminPage) } type adminStatus struct { - Maintenance store.Maintenance `json:"maintenance"` - UpdatePolicy appupdate.Policy `json:"updatePolicy"` - Library store.LibraryStats `json:"library"` - SyncRunning bool `json:"syncRunning"` - Runs []store.SyncRun `json:"runs"` - SyncEvery string `json:"syncEvery"` - ForYou store.ForYouStats `json:"forYou"` - ForYouRunning bool `json:"forYouRunning"` + Maintenance store.Maintenance `json:"maintenance"` + UpdatePolicy appupdate.Policy `json:"updatePolicy"` + Library store.LibraryStats `json:"library"` + SyncRunning bool `json:"syncRunning"` + Runs []store.SyncRun `json:"runs"` + SyncEvery string `json:"syncEvery"` + ForYou store.ForYouStats `json:"forYou"` + ForYouRunning bool `json:"forYouRunning"` + RequestPolicy store.RequestPolicy `json:"requestPolicy"` + PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"` + Features featureResponse `json:"features"` + RequestUsers []store.KnownUser `json:"requestUsers"` + Clients []store.KnownClient `json:"clients"` + SonarrReady bool `json:"sonarrReady"` + RadarrReady bool `json:"radarrReady"` } func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { @@ -131,6 +203,18 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { } forYouRunning = s.forYou.Running() } + requestPolicy, err := s.store.RequestPolicy(ctx) + if err != nil { + s.log.Warn("request policy read failed", "error", err) + } + requestUsers, err := s.store.KnownUsers(ctx) + if err != nil { + s.log.Warn("known users read failed", "error", err) + } + clients, err := s.store.KnownClients(ctx) + if err != nil { + s.log.Warn("known clients read failed", "error", err) + } writeJSON(w, http.StatusOK, adminStatus{ Maintenance: s.maintenance.get(), UpdatePolicy: s.updatePolicy.get(), @@ -140,9 +224,99 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { SyncEvery: s.cfg.SyncInterval.String(), ForYou: forYouStats, ForYouRunning: forYouRunning, + RequestPolicy: requestPolicy, + PlaybackPolicy: func() store.PlaybackPolicy { + policy, policyErr := s.store.PlaybackPolicy(ctx) + if policyErr != nil { + s.log.Warn("playback policy read failed", "error", policyErr) + return store.DefaultPlaybackPolicy() + } + return policy + }(), + Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion), + RequestUsers: requestUsers, + Clients: clients, + SonarrReady: s.sonarr != nil, + RadarrReady: s.radarr != nil, }) } +type playbackPolicyRequest struct { + PrerollEnabled bool `json:"prerollEnabled"` + PrerollDurationMs int64 `json:"prerollDurationMs"` +} + +func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Request) { + var req playbackPolicyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if req.PrerollDurationMs < 1_000 || req.PrerollDurationMs > 30_000 { + writeError(w, http.StatusBadRequest, "preroll duration must be between 1 and 30 seconds") + return + } + policy := store.PlaybackPolicy{ + PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs, + } + if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil { + s.log.Error("playback policy write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save playback policy") + return + } + stored, err := s.store.PlaybackPolicy(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not reload playback policy") + return + } + s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled, + "preroll_duration_ms", stored.PrerollDurationMs) + writeJSON(w, http.StatusOK, stored) +} + +type requestPolicyRequest struct { + AllowedUserIDs []string `json:"allowedUserIds"` +} + +func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request) { + var req requestPolicyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + known, err := s.store.KnownUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not validate users") + return + } + valid := make(map[string]bool, len(known)) + for _, user := range known { + valid[user.ID] = true + } + seen := map[string]bool{} + allowed := make([]string, 0, len(req.AllowedUserIDs)) + for _, id := range req.AllowedUserIDs { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + if !valid[id] { + writeError(w, http.StatusBadRequest, "unknown Emby user") + return + } + seen[id] = true + allowed = append(allowed, id) + } + policy := store.RequestPolicy{AllowedUserIDs: allowed} + if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil { + s.log.Error("request policy write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save request access") + return + } + s.log.Info("media request access changed", "users", len(allowed)) + writeJSON(w, http.StatusOK, policy) +} + type updatePolicyRequest struct { Enabled bool `json:"enabled"` LatestVersion string `json:"latestVersion"` @@ -171,6 +345,13 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) DownloadURL: strings.TrimSpace(req.DownloadURL), Notes: strings.TrimSpace(req.Notes), } + current := s.updatePolicy.get() + if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL { + // Changing "required" or release notes must not silently discard integrity + // metadata added by the signed release publisher. + policy.SHA256 = current.SHA256 + policy.SizeBytes = current.SizeBytes + } if req.Required { // Forcing means "nobody below the current build", so the floor is the latest. policy.MinimumVersion = policy.LatestVersion diff --git a/server/internal/api/admin.html b/server/internal/api/admin.html index 68bed4a..4bac39d 100644 --- a/server/internal/api/admin.html +++ b/server/internal/api/admin.html @@ -7,19 +7,75 @@ +
-
-

Memby admin

+
+
+

Memby gateway

+

Admin console

+

Manage the library, TV experience and server health.

+
connecting…
-
+

Library

Loading…
@@ -94,7 +260,7 @@
-
+

For You

Loading…
@@ -103,9 +269,45 @@
+
+

Per-user pressure test

+

+ Re-runs the shared weighted scorer over the user's prepared pool, after Emby + permission and parental-control filtering. Inspect every component and evidence reason. +

+
+ + + + + +
+
+
+
+ Choose a user to inspect their recommendations. +
-
+
+

Media requests

+

+ Choose who sees “Request it” when a library search has no matches. Movies are added + to Radarr and shows to Sonarr as unmonitored; no download search starts automatically. +

+
+
+ Loading users… +
+ +
+ +

Maintenance

Takes Memby offline for every TV, independently of Emby. Sign-in and all content @@ -119,7 +321,52 @@

-
+
+
+
+

Server control plane

+

Features without an app release

+

Every optional feature has a safe default, an explicit override and a remote recovery path.

+
Loading feature state…
+
+ +
+
Loading flags…
+
+ + + + loading… +
+

Client compatibility

+

Capability reports are sent on every request. A feature is only presented to clients that declare its contract.

+
+ + +
TVUserAppProtocolControl planeLast seen
No clients reported yet.
+
+ +
+

Playback experience

+

+ Controls presentation policy returned with every playback launch. Changes apply to + the next title opened on every gateway-connected TV; no app release is required. +

+
+ + + + +
+
+ +

App updates

TVs check on every launch. Optional shows a dismissable prompt; @@ -142,7 +389,7 @@

-
+

Row engagement

@@ -167,7 +414,7 @@
-
+

Recent imports

@@ -182,7 +429,7 @@ -
+

Live server events

+ {{if .Error}}{{end}} + + + + + {{else}} +
M
+

Install Memby

+ {{if .Ready}} +

The private Android TV client for this household’s Emby library.

+ Download Memby {{.Version}} +

Signed Android APK · {{.Size}}

+
    +
  1. Open this page on the TV using a browser or the Downloader app.
  2. +
  3. Choose Download. If Android asks, allow that app to install unknown apps.
  4. +
  5. Open the downloaded APK and choose Install, then launch Memby and sign in.
  6. +
+ {{if .Notes}}

Latest release: {{.Notes}}

{{end}} +
+ {{else}} +

The installer is not available yet. Publish a signed Memby release, then refresh this page.

+ {{end}} + {{end}} + + + diff --git a/server/internal/api/installer_auth.go b/server/internal/api/installer_auth.go new file mode 100644 index 0000000..f44cffe --- /dev/null +++ b/server/internal/api/installer_auth.go @@ -0,0 +1,206 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" +) + +const ( + installerCookieName = "memby_installer" + installerSessionTTL = 30 * time.Minute + installerDeviceID = "memby-web-installer" + installerDeviceName = "Memby Web Installer" +) + +func (s *Server) installerSecret() []byte { + if s.cfg.ReleasePublishToken == "" { + return nil + } + // Domain separation means a cookie/signature is not the release publisher token and + // cannot be presented to the upload endpoint. + sum := sha256.Sum256([]byte("memby installer access v1\x00" + s.cfg.ReleasePublishToken)) + return sum[:] +} + +func (s *Server) signInstallerValue(purpose string, payload []byte) []byte { + mac := hmac.New(sha256.New, s.installerSecret()) + _, _ = mac.Write([]byte(purpose)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write(payload) + return mac.Sum(nil) +} + +func (s *Server) newInstallerSession() (string, error) { + payload := make([]byte, 8+16) + binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix())) + if _, err := rand.Read(payload[8:]); err != nil { + return "", err + } + signature := s.signInstallerValue("session", payload) + return base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func (s *Server) validInstallerSession(r *http.Request) bool { + if len(s.installerSecret()) == 0 { + return false + } + cookie, err := r.Cookie(installerCookieName) + if err != nil { + return false + } + parts := strings.Split(cookie.Value, ".") + if len(parts) != 2 { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || len(payload) != 24 { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) { + return false + } + expires := int64(binary.BigEndian.Uint64(payload[:8])) + now := time.Now().Unix() + return expires > now && expires <= now+int64(installerSessionTTL/time.Second)+60 +} + +func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) { + http.SetCookie(w, &http.Cookie{ + Name: installerCookieName, + Value: value, + Path: "/", + MaxAge: int(installerSessionTTL / time.Second), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) clearInstallerCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: installerCookieName, + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) releaseAccessToken(filename string) string { + if len(s.installerSecret()) == 0 || !releaseFilenamePattern.MatchString(filename) { + return "" + } + return base64.RawURLEncoding.EncodeToString( + s.signInstallerValue("release", []byte(filename)), + ) +} + +func (s *Server) signedReleasePath(filename string) string { + token := s.releaseAccessToken(filename) + if token == "" { + return "" + } + return "/updates/" + filename + "?access=" + url.QueryEscape(token) +} + +func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool { + if s.validInstallerSession(r) { + return true + } + expected := s.releaseAccessToken(filename) + presented := strings.TrimSpace(r.URL.Query().Get("access")) + return expected != "" && hmac.Equal([]byte(presented), []byte(expected)) +} + +func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { + if len(s.installerSecret()) == 0 { + http.NotFound(w, r) + return + } + // Password authentication necessarily registers a device with Emby. Do not start + // it unless the service credential needed to remove that temporary record exists. + if s.cfg.SyncAPIKey == "" { + s.log.Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured") + s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", + http.StatusServiceUnavailable, "/install") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 8<<10) + if err := r.ParseForm(); err != nil { + s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusBadRequest, "/install") + return + } + next := cleanInstallerDestination(r.FormValue("next")) + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + if username == "" || password == "" { + s.renderAccessLogin(w, r, "Enter your username and password.", http.StatusBadRequest, next) + return + } + + auth, err := s.emby.Authenticate( + r.Context(), username, password, installerDeviceID, installerDeviceName, + ) + if err != nil { + s.log.Warn("installer Emby authentication failed", "username", username) + s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next) + return + } + // Authentication creates an Emby access token. The installer needs only proof that + // it succeeded, so retire the upstream session immediately and never persist it. + if err := s.emby.Logout(r.Context(), emby.Credentials{ + UserID: auth.User.ID, Token: auth.AccessToken, + DeviceID: installerDeviceID, DeviceName: installerDeviceName, + }); err != nil { + s.log.Warn("installer Emby session cleanup failed", "error", err) + } + if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{ + UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, + DeviceID: "memby-gateway", DeviceName: "Memby Gateway", + }, installerDeviceID); err != nil { + s.log.Error("installer Emby device cleanup failed", "error", err) + s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", + http.StatusBadGateway, next) + return + } + + session, err := s.newInstallerSession() + if err != nil { + s.log.Error("installer session generation failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not start installer session") + return + } + s.setInstallerCookie(w, session) + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) { + s.clearInstallerCookie(w) + http.Redirect(w, r, "/install", http.StatusSeeOther) +} + +func cleanInstallerDestination(value string) string { + value = strings.TrimSpace(value) + if value == "/admin/" { + return "/admin/" + } + if strings.HasPrefix(value, "/admin/") { + page := strings.TrimPrefix(value, "/admin/") + if adminPages[page] { + return value + } + } + return "/install" +} diff --git a/server/internal/api/items.go b/server/internal/api/items.go index 35ce25c..e13e659 100644 --- a/server/internal/api/items.go +++ b/server/internal/api/items.go @@ -1,11 +1,15 @@ package api import ( + "context" "encoding/json" "net/http" "net/url" + "strconv" + "strings" "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -17,6 +21,22 @@ type flagRequest struct { Value bool `json:"value"` } +type seasonFinaleResponse struct { + SeasonFinale bool `json:"seasonFinale"` + SeriesName string `json:"seriesName,omitempty"` + SeasonNumber int `json:"seasonNumber,omitempty"` + EpisodeNumber int `json:"episodeNumber,omitempty"` +} + +type finaleEmbyItem struct { + Type string `json:"Type"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + ParentIndexNumber int `json:"ParentIndexNumber"` + IndexNumber int `json:"IndexNumber"` + ProviderIDs map[string]string `json:"ProviderIds"` +} + // handleItem serves full metadata for one item. The TV asks for this only after D-pad // focus settles, so it is worth caching for longer than a home row. func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.Session) { @@ -28,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S } // Version the entry when the detail contract grows so older cached payloads cannot // hide newly requested fields such as People. - key := cache.UserKey(sess.EmbyUserID, "item:v2:"+itemID) + key := cache.UserKey(sess.EmbyUserID, "item:v3:"+itemID) if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") @@ -48,6 +68,131 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S writeRaw(w, http.StatusOK, item) } +// handleSeasonFinale verifies an episode against Sonarr's complete season, including +// future episodes. Looking only at Emby's downloaded files would call every current +// weekly episode a finale until the following episode arrived. +func (s *Server) handleSeasonFinale(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 + } + empty := seasonFinaleResponse{} + if s.sonarr == nil { + writeJSON(w, http.StatusOK, empty) + return + } + key := cache.UserKey(sess.EmbyUserID, "season-finale:v1:"+itemID) + if raw, err := s.cache.Get(ctx, key); err == nil { + writeRaw(w, http.StatusOK, raw) + return + } + + currentRaw, err := s.emby.Item( + ctx, credentials(sess), itemID, + "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber", + ) + if err != nil { + s.writeUpstreamError(w, err, "could not inspect the playing episode") + return + } + var current finaleEmbyItem + if json.Unmarshal(currentRaw, ¤t) != nil || + !strings.EqualFold(current.Type, "Episode") || current.SeriesID == "" || + current.ParentIndexNumber <= 0 || current.IndexNumber <= 0 { + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + + seriesRaw, err := s.emby.Item(ctx, credentials(sess), current.SeriesID, "ProviderIds") + if err != nil { + s.log.Warn("season finale series metadata unavailable", "item", itemID, "error", err) + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + var seriesItem finaleEmbyItem + if json.Unmarshal(seriesRaw, &seriesItem) != nil { + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + tvdbID, err := strconv.Atoi(providerID(seriesItem.ProviderIDs, "tvdb")) + if err != nil || tvdbID <= 0 { + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + + series, err := s.sonarr.Series(ctx) + if err != nil { + s.log.Warn("season finale Sonarr series unavailable", "item", itemID, "error", err) + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + sonarrSeriesID := 0 + for _, candidate := range series { + if candidate.TVDBID == tvdbID { + sonarrSeriesID = candidate.ID + break + } + } + if sonarrSeriesID == 0 { + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + episodes, err := s.sonarr.Episodes(ctx, sonarrSeriesID) + if err != nil { + s.log.Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err) + s.writeSeasonFinaleResponse(ctx, key, empty, w) + return + } + result := seasonFinaleResponse{ + SeasonFinale: isSeasonFinale(current.ParentIndexNumber, current.IndexNumber, episodes), + SeriesName: current.SeriesName, + SeasonNumber: current.ParentIndexNumber, + EpisodeNumber: current.IndexNumber, + } + if !result.SeasonFinale { + result = empty + } + s.writeSeasonFinaleResponse(ctx, key, result, w) +} + +func (s *Server) writeSeasonFinaleResponse( + ctx context.Context, key string, result seasonFinaleResponse, w http.ResponseWriter, +) { + body, err := json.Marshal(result) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not encode finale status") + return + } + if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { + s.log.Warn("season finale cache write failed", "error", err) + } + writeRaw(w, http.StatusOK, body) +} + +func providerID(ids map[string]string, wanted string) string { + for key, value := range ids { + if strings.EqualFold(key, wanted) { + return value + } + } + return "" +} + +func isSeasonFinale(seasonNumber, episodeNumber int, episodes []sonarr.Episode) bool { + if seasonNumber <= 0 || episodeNumber <= 0 { + return false + } + lastEpisode := 0 + for _, episode := range episodes { + if episode.SeasonNumber == seasonNumber && episode.EpisodeNumber > lastEpisode { + lastEpisode = episode.EpisodeNumber + } + } + return lastEpisode > 0 && episodeNumber == lastEpisode +} + // handleSeriesEpisodes supplies the complete episode browser in one cached response. // The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby. func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) { @@ -154,7 +299,6 @@ func (s *Server) setFlag( } if s.forYou != nil { s.forYou.MarkDirty(r.Context(), sess) - s.forYou.RefreshAsync(sess, true) } writeRaw(w, http.StatusOK, userData) } diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index e734481..5f925ba 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -101,11 +101,15 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ s // Nothing to celebrate while the service is down, and the client is showing the // maintenance screen anyway. if !state.Enabled { - if found := s.sonarrAiredAlerts(r.Context()); len(found) > 0 { + if found := mergeAlerts( + s.publishedAlerts(r.Context()), + s.sonarrAiredAlerts(r.Context()), + ); len(found) > 0 { alerts = found } } compatible, compatibilityMessage := compatibilityFor(r) + featurePolicy := s.currentFeaturePolicy(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "maintenance": state.Enabled, "message": message, @@ -115,5 +119,9 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ s "clientVersion": clientVersion(r), "clientProtocol": clientProtocol(r), "serverProtocol": membyProtocolVersion, + "featureSchemaVersion": featureSchemaVersion, + "featureRevision": featurePolicy.Revision, + "safeMode": featurePolicy.SafeMode, + "features": featureMap(featurePolicy, clientProtocolNumber(r), clientCapabilities(r)), }) } diff --git a/server/internal/api/my_shows.go b/server/internal/api/my_shows.go new file mode 100644 index 0000000..ed98ba0 --- /dev/null +++ b/server/internal/api/my_shows.go @@ -0,0 +1,255 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/sonarr" + "github.com/ponzischeme89/memby/server/internal/store" +) + +type myShowResponse struct { + store.UserShow + SonarrStatus string `json:"sonarrStatus"` + NextEpisode *time.Time `json:"nextEpisode,omitempty"` + Lifecycle string `json:"lifecycle"` + Monitored bool `json:"monitored"` +} + +type myShowsResponse struct { + Shows []myShowResponse `json:"shows"` +} + +type notificationsResponse struct { + Notifications []store.UserNotification `json:"notifications"` + Preferences store.NotificationPreferences `json:"preferences"` +} + +func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) { + switch r.Method { + case http.MethodGet: + s.listMyShows(w, r, sess) + case http.MethodPost: + var show store.UserShow + if !decodeJSON(w, r, &show) { + return + } + show.ItemID = strings.TrimSpace(show.ItemID) + show.Title = strings.TrimSpace(show.Title) + if show.ItemID == "" || show.Title == "" { + writeError(w, http.StatusBadRequest, "itemId and title are required") + return + } + if err := s.store.SaveUserShow(r.Context(), sess.EmbyUserID, show); err != nil { + s.log.Error("save user show failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save show") + return + } + s.listMyShows(w, r, sess) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleMyShow(w http.ResponseWriter, r *http.Request, sess store.Session) { + itemID := strings.TrimSpace(r.PathValue("id")) + if itemID == "" { + writeError(w, http.StatusBadRequest, "show id is required") + return + } + if err := s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, itemID); err != nil { + s.log.Error("delete user show failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not remove show") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) { + saved, err := s.store.UserShows(r.Context(), sess.EmbyUserID) + if err != nil { + s.log.Error("list user shows failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not load shows") + return + } + sonarrSeries := []sonarr.Series{} + if s.sonarr != nil { + if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil { + sonarrSeries = value + } else { + s.log.Warn("Sonarr status unavailable for My Shows", "error", seriesErr) + } + } + result := make([]myShowResponse, 0, len(saved)) + for _, show := range saved { + matched := matchSonarrSeries(show, sonarrSeries) + response := myShowResponse{ + UserShow: show, + SonarrStatus: "Not found", + Lifecycle: "Unknown", + } + if matched != nil { + response.SonarrStatus = sonarrStatus(*matched) + response.NextEpisode = matched.NextAiring + response.Lifecycle = seriesLifecycle(matched.Status) + response.Monitored = matched.Monitored + } + result = append(result, response) + } + writeJSON(w, http.StatusOK, myShowsResponse{Shows: result}) +} + +func matchSonarrSeries(show store.UserShow, all []sonarr.Series) *sonarr.Series { + key := normalizedShowTitle(show.Title) + for i := range all { + candidate := &all[i] + if normalizedShowTitle(candidate.Title) != key { + continue + } + if show.Year == nil || candidate.Year == 0 || candidate.Year == *show.Year { + return candidate + } + } + return nil +} + +func normalizedShowTitle(value string) string { + return strings.Map(func(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + ('a' - 'A') + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, value) +} + +func seriesLifecycle(status string) string { + switch strings.ToLower(status) { + case "continuing", "upcoming": + return "Continuing" + case "ended", "deleted": + return "Cancelled" + default: + if status == "" { + return "Unknown" + } + return strings.ToUpper(status[:1]) + strings.ToLower(status[1:]) + } +} + +func isContinuingSonarrStatus(status string) bool { + return strings.EqualFold(status, "continuing") || strings.EqualFold(status, "upcoming") +} + +func sonarrStatus(series sonarr.Series) string { + if series.Monitored { + return "Monitored" + } + return "Not monitored" +} + +func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) { + prefs, err := s.store.NotificationPreferences(r.Context(), sess.EmbyUserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load notification preferences") + return + } + if r.Method == http.MethodPut { + if !decodeJSON(w, r, &prefs) { + return + } + if err := s.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil { + writeError(w, http.StatusInternalServerError, "could not save notification preferences") + return + } + } + if prefs.Enabled && prefs.ShowReturnAlerts { + s.syncReturnNotifications(r, sess, prefs) + } + notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load notifications") + return + } + writeJSON(w, http.StatusOK, notificationsResponse{ + Notifications: notifications, + Preferences: prefs, + }) +} + +func (s *Server) syncReturnNotifications( + r *http.Request, sess store.Session, prefs store.NotificationPreferences, +) { + if s.sonarr == nil { + return + } + shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID) + if err != nil { + return + } + all, err := s.sonarr.Series(r.Context()) + if err != nil { + return + } + now := time.Now() + until := now.AddDate(0, 0, prefs.LeadDays) + for _, show := range shows { + series := matchSonarrSeries(show, all) + if series == nil || series.NextAiring == nil || + series.NextAiring.Before(now) || series.NextAiring.After(until) { + continue + } + days := int(series.NextAiring.Sub(now).Hours()/24) + 1 + message := fmt.Sprintf("%s returns in %d days.", show.Title, days) + if days <= 1 { + message = show.Title + " returns tomorrow." + } else if days == 7 { + message = show.Title + " returns next week." + } + sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02") + _ = s.store.UpsertNotification( + r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID, + "New episode coming", message, series.NextAiring, + ) + } +} + +func (s *Server) handleNotificationAction( + w http.ResponseWriter, r *http.Request, sess store.Session, +) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeError(w, http.StatusBadRequest, "invalid notification id") + return + } + switch r.PathValue("action") { + case "read": + err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id) + case "dismiss": + err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id) + default: + writeError(w, http.StatusNotFound, "unknown notification action") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "could not update notification") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return false + } + return true +} diff --git a/server/internal/api/my_shows_test.go b/server/internal/api/my_shows_test.go new file mode 100644 index 0000000..c23136b --- /dev/null +++ b/server/internal/api/my_shows_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "testing" + + "github.com/ponzischeme89/memby/server/internal/sonarr" + "github.com/ponzischeme89/memby/server/internal/store" +) + +func TestMatchSonarrSeriesUsesNormalizedTitleAndYear(t *testing.T) { + year := 2026 + all := []sonarr.Series{ + {ID: 1, Title: "The Other Show", Year: 2026}, + {ID: 2, Title: "Northbound: NZ", Year: 2026}, + } + got := matchSonarrSeries(store.UserShow{Title: "Northbound - NZ", Year: &year}, all) + if got == nil || got.ID != 2 { + t.Fatalf("match = %+v, want series 2", got) + } +} + +func TestSeriesLifecycleUsesViewerFacingStates(t *testing.T) { + tests := map[string]string{ + "continuing": "Continuing", + "upcoming": "Continuing", + "ended": "Cancelled", + "deleted": "Cancelled", + "": "Unknown", + } + for input, want := range tests { + if got := seriesLifecycle(input); got != want { + t.Errorf("seriesLifecycle(%q) = %q, want %q", input, got, want) + } + } +} + +func TestContinuingSonarrStatus(t *testing.T) { + for _, status := range []string{"continuing", "Continuing", "upcoming"} { + if !isContinuingSonarrStatus(status) { + t.Errorf("%q should auto-follow", status) + } + } + for _, status := range []string{"ended", "deleted", ""} { + if isContinuingSonarrStatus(status) { + t.Errorf("%q should not auto-follow", status) + } + } +} + +func TestAutoFollowStartsHalfwayThroughEpisode(t *testing.T) { + if shouldAutoFollowShow("started", 900, 1_000) { + t.Fatal("starting or resuming playback must not auto-follow") + } + if shouldAutoFollowShow("progress", 499, 1_000) { + t.Fatal("less than half an episode must not auto-follow") + } + if !shouldAutoFollowShow("progress", 500, 1_000) { + t.Fatal("half an episode should auto-follow") + } + if shouldAutoFollowShow("progress", 500, 0) { + t.Fatal("unknown durations must not auto-follow") + } +} diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index e0727b2..9215f94 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" "net/url" "strconv" @@ -15,14 +16,20 @@ import ( const ticksPerMillisecond = 10_000 type playbackResponse struct { - ItemID string `json:"itemId"` - Title string `json:"title"` - URL string `json:"url"` - ResumePositionMs int64 `json:"resumePositionMs"` - Subtitles []playableSubtitle `json:"subtitles"` - MediaSourceID string `json:"mediaSourceId"` - PlaySessionID string `json:"playSessionId"` - PlayMethod string `json:"playMethod"` + ItemID string `json:"itemId"` + Title string `json:"title"` + Overview string `json:"overview,omitempty"` + SeriesName string `json:"seriesName,omitempty"` + EpisodeCode string `json:"episodeCode,omitempty"` + RuntimeMs int64 `json:"runtimeMs,omitempty"` + PrerollEnabled bool `json:"prerollEnabled"` + PrerollDurationMs int64 `json:"prerollDurationMs"` + URL string `json:"url"` + ResumePositionMs int64 `json:"resumePositionMs"` + Subtitles []playableSubtitle `json:"subtitles"` + MediaSourceID string `json:"mediaSourceId"` + PlaySessionID string `json:"playSessionId"` + PlayMethod string `json:"playMethod"` } type playableSubtitle struct { @@ -41,6 +48,7 @@ type playableSubtitle struct { type playbackReport struct { ItemID string `json:"itemId"` PositionMs int64 `json:"positionMs"` + DurationMs int64 `json:"durationMs,omitempty"` IsPaused bool `json:"isPaused"` MediaSourceID string `json:"mediaSourceId"` PlaySessionID string `json:"playSessionId"` @@ -48,6 +56,10 @@ type playbackReport struct { EventName string `json:"eventName,omitempty"` } +type playbackReportResponse struct { + AutoFollowedShowTitle string `json:"autoFollowedShowTitle,omitempty"` +} + // nextEpisodeResponse carries the episode that follows the one being watched. Item is // Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so // the client decodes it into the same BaseItem it uses everywhere else. @@ -117,20 +129,35 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto } subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "", + queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess), ) streamURL := s.emby.StreamURL(cred, target.ID) if negotiatedURL != "" { streamURL = negotiatedURL } + playbackPolicy := store.DefaultPlaybackPolicy() + if s.store != nil { + if policy, policyErr := s.store.PlaybackPolicy(ctx); policyErr == nil { + playbackPolicy = policy + } else { + s.log.Warn("playback policy unavailable", "error", policyErr) + } + } writeJSON(w, http.StatusOK, playbackResponse{ - ItemID: target.ID, - Title: title, - URL: streamURL, - ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), - Subtitles: subtitles, - MediaSourceID: mediaSourceID, - PlaySessionID: playSessionID, - PlayMethod: playMethod, + ItemID: target.ID, + Title: title, + Overview: target.Overview, + SeriesName: target.SeriesName, + EpisodeCode: episodeCode(target), + RuntimeMs: max64(target.RunTimeTicks/ticksPerMillisecond, 0), + PrerollEnabled: playbackPolicy.PrerollEnabled && s.featureEnabled(ctx, featureSonarrPreroll), + PrerollDurationMs: playbackPolicy.PrerollDurationMs, + URL: streamURL, + ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: subtitles, + MediaSourceID: mediaSourceID, + PlaySessionID: playSessionID, + PlayMethod: playMethod, }) } @@ -164,7 +191,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials nextUp, err := s.emby.NextUp(ctx, cred, url.Values{ "SeriesId": {seriesID}, "Limit": {"1"}, - "Fields": {"RunTimeTicks"}, + "Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"}, "EnableUserData": {"true"}, }) if err == nil && len(nextUp.Items) > 0 { @@ -175,7 +202,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{ "Limit": {"1"}, - "Fields": {"RunTimeTicks"}, + "Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"}, "EnableUserData": {"true"}, }) if err != nil { @@ -191,6 +218,13 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials return &summary, nil } +func episodeCode(item emby.Summary) string { + if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 { + return "" + } + return fmt.Sprintf("S%02dE%02d", item.ParentIndexNumber, item.IndexNumber) +} + // handleNextEpisode resolves the episode that follows the one being watched, so the player // can offer a "next up" countdown without the TV needing to know how Emby orders a series. // @@ -249,13 +283,18 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" { title = series + " – " + title } - subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles( - ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", + subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( + ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false, + s.effectivePlaybackCapabilities(ctx, sess), ) + streamURL := s.emby.StreamURL(cred, next.ID) + if negotiatedURL != "" { + streamURL = negotiatedURL + } writeJSON(w, http.StatusOK, nextEpisodeResponse{ Item: raw, Title: title, - URL: s.emby.StreamURL(cred, next.ID), + URL: streamURL, ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), Subtitles: subtitles, MediaSourceID: mediaSourceID, @@ -266,10 +305,12 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess func (s *Server) playbackSubtitles( ctx context.Context, cred emby.Credentials, itemID string, startTicks int64, - subtitleIndex *int, currentPlaySessionID string, + subtitleIndex *int, currentPlaySessionID string, forceTranscode bool, + capabilities emby.PlaybackCapabilities, ) ([]playableSubtitle, string, string, string, string) { info, err := s.emby.PlaybackInfo( - ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, + ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode, + capabilities, ) if err != nil { s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err) @@ -326,13 +367,128 @@ func (s *Server) playbackSubtitles( Codec: stream.Codec, }) } - negotiatedURL := "" - playMethod := "DirectPlay" - if subtitleIndex != nil && source.TranscodingURL != "" { - negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL) - playMethod = "Transcode" + delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil) + if delivery != "" { + delivery = s.emby.DeliveryURL(cred, delivery) } - return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod + return out, source.ID, info.PlaySessionID, delivery, playMethod +} + +func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities { + capabilities := emby.PlaybackCapabilities{} + for _, value := range sess.ClientCapabilities { + switch value { + case "video_h264_profile_baseline": + capabilities.H264Profiles = append(capabilities.H264Profiles, "baseline") + case "video_h264_profile_constrained_baseline": + capabilities.H264Profiles = append(capabilities.H264Profiles, "constrained baseline") + case "video_h264_profile_main": + capabilities.H264Profiles = append(capabilities.H264Profiles, "main") + case "video_h264_profile_high": + capabilities.H264Profiles = append(capabilities.H264Profiles, "high") + case "video_h264_profile_high10": + capabilities.H264Profiles = append(capabilities.H264Profiles, "high 10") + case "video_hevc_decode": + capabilities.HEVC = true + case "video_hevc_profile_main": + capabilities.HEVCMain = true + case "video_hevc_profile_main10": + capabilities.HEVCMain10 = true + case "video_hevc_hdr10": + capabilities.HEVCHDR10 = true + case "video_hevc_hdr10plus": + capabilities.HEVCHDR10Plus = true + case "video_hevc_dolby_vision": + capabilities.HEVCDolbyVision = true + default: + parsePlaybackCapabilityValue(value, &capabilities) + } + } + return capabilities +} + +func (s *Server) effectivePlaybackCapabilities( + ctx context.Context, sess store.Session, +) emby.PlaybackCapabilities { + capabilities := sessionPlaybackCapabilities(sess) + if !s.featureEnabled(ctx, featureHEVCDirectPlay) { + capabilities.HEVC = false + capabilities.HEVCMain = false + capabilities.HEVCMain10 = false + capabilities.HEVCMainLevel = 0 + capabilities.HEVCMain10Level = 0 + capabilities.HEVCMaxWidth = 0 + capabilities.HEVCMaxHeight = 0 + capabilities.HEVCHDR10 = false + capabilities.HEVCHDR10Plus = false + capabilities.HEVCDolbyVision = false + } + return capabilities +} + +func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) { + parseIntCapability(value, "video_h264_level_", &capabilities.H264Level) + parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level) + parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel) + parseIntCapability(value, "video_hevc_main10_level_", &capabilities.HEVCMain10Level) + parseResolutionCapability( + value, "video_h264_max_", &capabilities.H264MaxWidth, &capabilities.H264MaxHeight, + ) + parseResolutionCapability( + value, "video_hevc_max_", &capabilities.HEVCMaxWidth, &capabilities.HEVCMaxHeight, + ) +} + +func parseIntCapability(value, prefix string, destination *int) { + if !strings.HasPrefix(value, prefix) { + return + } + parsed, err := strconv.Atoi(strings.TrimPrefix(value, prefix)) + if err == nil && parsed > 0 { + *destination = parsed + } +} + +func parseResolutionCapability(value, prefix string, width, height *int) { + if !strings.HasPrefix(value, prefix) { + return + } + parts := strings.Split(strings.TrimPrefix(value, prefix), "x") + if len(parts) != 2 { + return + } + parsedWidth, widthErr := strconv.Atoi(parts[0]) + parsedHeight, heightErr := strconv.Atoi(parts[1]) + if widthErr == nil && heightErr == nil && parsedWidth > 0 && parsedHeight > 0 { + *width, *height = parsedWidth, parsedHeight + } +} + +func selectPlaybackDelivery(source emby.MediaSourceInfo, forceTranscode bool) (string, string) { + if forceTranscode && source.TranscodingURL != "" { + return source.TranscodingURL, "Transcode" + } + if source.SupportsDirectPlay { + return "", "DirectPlay" + } + if source.SupportsDirectStream && source.DirectStreamURL != "" { + return source.DirectStreamURL, "DirectStream" + } + if source.SupportsTranscoding && source.TranscodingURL != "" { + return source.TranscodingURL, "Transcode" + } + if source.DirectStreamURL != "" { + return source.DirectStreamURL, "DirectStream" + } + if source.TranscodingURL != "" { + return source.TranscodingURL, "Transcode" + } + return "", "DirectPlay" +} + +func queryBool(r *http.Request, name string) bool { + value, err := strconv.ParseBool(strings.TrimSpace(r.URL.Query().Get(name))) + return err == nil && value } func subtitleExtension(codec string) string { @@ -445,17 +601,90 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { s.log.Warn("cache invalidation failed", "error", err) } - // Finishing something is the one event that genuinely changes viewing history, - // so it is also the only thing that retires the recommendation rows. - if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil { - s.log.Warn("recommendation invalidation failed", "error", err) - } - if s.forYou != nil { - s.forYou.MarkDirty(r.Context(), sess) - s.forYou.RefreshAsync(sess, true) - } + // Recommendation taste changes slowly. Tracearr marks this user's prepared + // profile dirty only when the session first becomes terminal; the daily builder + // then refreshes it without turning every player exit into catalogue-wide work. } - w.WriteHeader(http.StatusNoContent) + + response := playbackReportResponse{} + if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) && + s.featureEnabled(r.Context(), featureAutomaticMyShows) { + response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID) + } + writeJSON(w, http.StatusOK, response) +} + +// Half an episode is a meaningful intent signal without making somebody finish an +// episode they dislike. The insert below is the durable deduplication boundary, so +// later ten-second progress reports are harmless. +func shouldAutoFollowShow(phase string, positionMs, durationMs int64) bool { + return phase != "started" && durationMs > 0 && positionMs >= (durationMs+1)/2 +} + +func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, episodeID string) string { + if s.sonarr == nil || s.store == nil { + return "" + } + rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId") + if err != nil { + s.log.Warn("auto-follow episode lookup failed", "error", err) + return "" + } + var episode struct { + Type string `json:"Type"` + SeriesID string `json:"SeriesId"` + } + if json.Unmarshal(rawEpisode, &episode) != nil || !strings.EqualFold(episode.Type, "Episode") || episode.SeriesID == "" { + return "" + } + rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear") + if err != nil { + s.log.Warn("auto-follow series lookup failed", "error", err) + return "" + } + var seriesItem struct { + Name string `json:"Name"` + ProductionYear *int `json:"ProductionYear"` + ImageTags map[string]string `json:"ImageTags"` + } + if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" { + return "" + } + sonarrSeries, err := s.sonarr.Series(ctx) + if err != nil { + s.log.Warn("auto-follow Sonarr lookup failed", "error", err) + return "" + } + matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries) + if matched == nil || !isContinuingSonarrStatus(matched.Status) { + return "" + } + show := store.UserShow{ + ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear, + ImageTag: seriesItem.ImageTags["Primary"], + } + inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show) + if err != nil { + s.log.Warn("auto-follow save failed", "error", err) + return "" + } + if !inserted { + return "" + } + prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID) + if err != nil { + s.log.Warn("auto-follow notification preferences unavailable", "error", err) + return "" + } + if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) { + _ = s.store.UpsertNotification( + ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow", + episode.SeriesID, "Added to My Shows", + seriesItem.Name+" was added because you started watching it and it is still continuing.", nil, + ) + return seriesItem.Name + } + return "" } func max64(v, floor int64) int64 { diff --git a/server/internal/api/playback_subtitles_test.go b/server/internal/api/playback_subtitles_test.go index 3696587..9646762 100644 --- a/server/internal/api/playback_subtitles_test.go +++ b/server/internal/api/playback_subtitles_test.go @@ -1,6 +1,11 @@ package api -import "testing" +import ( + "testing" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) func TestSubtitleMIME(t *testing.T) { tests := map[string]string{ @@ -18,6 +23,47 @@ func TestSubtitleMIME(t *testing.T) { } } +func TestSelectPlaybackDeliveryUsesNegotiatedDirectStream(t *testing.T) { + source := emby.MediaSourceInfo{ + SupportsDirectStream: true, + DirectStreamURL: "/Videos/item/stream.mp4", + TranscodingURL: "/Videos/item/master.m3u8", + } + got, method := selectPlaybackDelivery(source, false) + if got != source.DirectStreamURL || method != "DirectStream" { + t.Fatalf("selectPlaybackDelivery() = %q, %q", got, method) + } +} + +func TestSelectPlaybackDeliveryCanForceCompatibleTranscode(t *testing.T) { + source := emby.MediaSourceInfo{ + SupportsDirectPlay: true, + TranscodingURL: "/Videos/item/master.m3u8", + } + got, method := selectPlaybackDelivery(source, true) + if got != source.TranscodingURL || method != "Transcode" { + t.Fatalf("selectPlaybackDelivery() = %q, %q", got, method) + } +} + +func TestSessionPlaybackCapabilitiesParseDecoderDetails(t *testing.T) { + legacy := sessionPlaybackCapabilities(store.Session{}) + if legacy.HEVC { + t.Fatal("legacy client must not implicitly claim HEVC") + } + got := sessionPlaybackCapabilities(store.Session{ClientCapabilities: []string{ + "video_h264_profile_high", "video_h264_level_52", "video_h264_max_3840x2160", + "video_hevc_decode", "video_hevc_profile_main10", "video_hevc_main10_level_153", + "video_hevc_max_3840x2160", "video_hevc_hdr10", + }}) + if !got.HEVC || !got.HEVCMain10 || !got.HEVCHDR10 || got.HEVCMain10Level != 153 { + t.Fatalf("HEVC capabilities = %+v", got) + } + if got.H264Level != 52 || got.H264MaxWidth != 3840 || got.H264MaxHeight != 2160 { + t.Fatalf("H264 capabilities = %+v", got) + } +} + func TestSubtitleMIMEFallsBackToDeliveryExtension(t *testing.T) { if got := subtitleMIME("", "https://emby.example/subtitles/4/stream.vtt?api_key=x"); got != "text/vtt" { t.Fatalf("subtitleMIME delivery fallback = %q", got) diff --git a/server/internal/api/radarr.go b/server/internal/api/radarr.go new file mode 100644 index 0000000..69ae95f --- /dev/null +++ b/server/internal/api/radarr.go @@ -0,0 +1,228 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/radarr" + "github.com/ponzischeme89/memby/server/internal/recommend" +) + +const radarrCalendarCachePrefix = "radarr:calendar:v2:" +const radarrScheduleDays = 5 +const radarrTheatricalDelayDays = 30 + +type radarrRelease struct { + at time.Time + estimated bool +} + +type radarrScheduleItem struct { + ID string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + Overview string `json:"Overview,omitempty"` + ProductionYear int `json:"ProductionYear,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Genres []string `json:"Genres"` + ImageTags map[string]string `json:"ImageTags"` + BackdropImageTags []string `json:"BackdropImageTags"` + MembySource string `json:"MembySource"` + MembyAirsAt string `json:"MembyAirsAt"` + MembyAirDayLabel string `json:"MembyAirDayLabel"` + MembyAirLabel string `json:"MembyAirLabel"` + MembyAvailability string `json:"MembyAvailability"` + MembyAvailabilityText string `json:"MembyAvailabilityText"` + MembyPlayable bool `json:"MembyPlayable"` +} + +func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) { + if s.radarr == nil { + return nil, nil + } + location := s.cfg.RadarrLocation + if location == nil { + location = time.Local + } + now := time.Now().In(location) + dayStart := localDayStart(now, location) + cacheKey := radarrCalendarCachePrefix + dayStart.Format("2006-01-02") + if row := s.cachedRadarrRow(ctx, cacheKey); row != nil { + return row, nil + } + + s.radarrMu.Lock() + defer s.radarrMu.Unlock() + if row := s.cachedRadarrRow(ctx, cacheKey); row != nil { + return row, nil + } + + // A cinema release can stand in for an unknown digital date at cinema + 30 days, + // so include the preceding 30 days in the Radarr query. buildRadarrRow applies the + // actual five-day effective-release window after the response arrives. + movies, err := s.radarr.Calendar( + ctx, + dayStart.AddDate(0, 0, -radarrTheatricalDelayDays), + dayStart.AddDate(0, 0, radarrScheduleDays), + ) + if err != nil { + return nil, err + } + row, err := buildRadarrRow(movies, now, location) + if err != nil { + return nil, err + } + if body, marshalErr := json.Marshal(row); marshalErr == nil { + if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.RadarrTTL); cacheErr != nil { + s.log.Warn("radarr calendar cache write failed", "error", cacheErr) + } + } + return row, nil +} + +func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row { + raw, err := s.cache.Get(ctx, key) + if err != nil { + return nil + } + var row recommend.Row + if json.Unmarshal(raw, &row) != nil { + return nil + } + return &row +} + +func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) { + sort.SliceStable(movies, func(i, j int) bool { + left, leftOK := effectiveRadarrRelease(movies[i]) + right, rightOK := effectiveRadarrRelease(movies[j]) + if !leftOK { + return false + } + if !rightOK { + return true + } + return left.at.Before(right.at) + }) + + items := make([]json.RawMessage, 0, len(movies)) + dayStart := localDayStart(now, location) + windowEnd := dayStart.AddDate(0, 0, radarrScheduleDays) + for _, movie := range movies { + release, ok := effectiveRadarrRelease(movie) + if !ok { + continue + } + localRelease := release.at.In(location) + if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) { + continue + } + raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location)) + if err != nil { + return nil, err + } + items = append(items, raw) + } + return &recommend.Row{ + ID: "radarr-upcoming-movies", + Title: "Upcoming Movie releases", + Kind: "movie-schedule", + Items: items, + }, nil +} + +// effectiveRadarrRelease prefers Radarr's actual digital date. Cinema + 30 days is +// only a fallback when Radarr has no digital date at all. In particular, an old movie +// with an old digital date cannot appear because of a newer cinema/re-release date. +func effectiveRadarrRelease(movie radarr.Movie) (radarrRelease, bool) { + if movie.DigitalRelease != nil { + return radarrRelease{at: *movie.DigitalRelease}, true + } + if movie.InCinemas != nil { + return radarrRelease{ + at: movie.InCinemas.AddDate(0, 0, radarrTheatricalDelayDays), + estimated: true, + }, true + } + return radarrRelease{}, false +} + +func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Time, location *time.Location) radarrScheduleItem { + availabilityText := "Upcoming digital release" + if release.estimated { + availabilityText = "Estimated digital release" + } + item := radarrScheduleItem{ + ID: fmt.Sprintf("radarr:%d", movie.ID), + Name: movie.Title, + Type: "MembyRadarrMovie", + Overview: movie.Overview, + ProductionYear: movie.Year, + RunTimeTicks: int64(movie.Runtime) * 600_000_000, + Genres: nonNilStrings(movie.Genres), + ImageTags: map[string]string{}, + BackdropImageTags: []string{}, + MembySource: "radarr", + MembyAvailability: "upcoming", + MembyAvailabilityText: availabilityText, + MembyPlayable: false, + } + if hasRadarrCover(movie.Images, "poster") { + item.ImageTags["Primary"] = "radarr" + } + if hasRadarrCover(movie.Images, "fanart") { + item.BackdropImageTags = []string{"radarr"} + } + localRelease := release.at.In(location) + item.MembyAirsAt = localRelease.Format(time.RFC3339) + item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location) + item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated) + switch { + case movie.HasFile: + item.MembyAvailability = "available" + item.MembyAvailabilityText = "Downloaded" + if movie.MovieFile != nil && movie.MovieFile.DateAdded != nil { + item.MembyAvailabilityText = "Added at " + + movie.MovieFile.DateAdded.In(location).Format("3:04 PM") + } + case !movie.Monitored: + item.MembyAvailability = "unmonitored" + item.MembyAvailabilityText = "Not monitored" + case release.at.Before(now): + item.MembyAvailability = "awaiting" + item.MembyAvailabilityText = "Awaiting download" + } + return item +} + +func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string { + release = release.In(location) + now = now.In(location) + today := localDayStart(now, location) + releaseDay := localDayStart(release, location) + prefix := "Digital release " + if estimated { + prefix = "Estimated digital release " + } + switch { + case releaseDay.Equal(today): + return prefix + "today" + case releaseDay.Equal(today.AddDate(0, 0, 1)): + return prefix + "tomorrow" + default: + return prefix + release.Format("Monday") + } +} + +func hasRadarrCover(images []radarr.Image, coverType string) bool { + for _, image := range images { + if strings.EqualFold(image.CoverType, coverType) { + return true + } + } + return false +} diff --git a/server/internal/api/radarr_alerts.go b/server/internal/api/radarr_alerts.go new file mode 100644 index 0000000..f5d960a --- /dev/null +++ b/server/internal/api/radarr_alerts.go @@ -0,0 +1,151 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Radarr's import notification arrives as a webhook, which is why this is the one part +// of the gateway something else pushes to. Polling the calendar could only ever notice +// an import a minute or two late and only for titles inside the five-day window; +// "a movie just landed" is an event, so it is delivered as one. The alert itself goes +// into the shared store in alerts.go, like every other event. + +// radarrWebhookPayload is the subset of Radarr's webhook body Memby reads. Radarr sends +// considerably more; anything not named here is ignored on purpose, so a Radarr upgrade +// that adds fields cannot break the hook. +type radarrWebhookPayload struct { + EventType string `json:"eventType"` + IsUpgrade bool `json:"isUpgrade"` + Movie struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + TMDBID int `json:"tmdbId"` + } `json:"movie"` + MovieFile struct { + ID int `json:"id"` + Quality string `json:"quality"` + } `json:"movieFile"` +} + +// handleRadarrWebhook accepts Radarr's "On Import" notification. +// +// It sits outside both the auth middleware (Radarr has no Memby session) and the +// maintenance gate (an event dropped while maintenance is on is lost for good, and +// recording one costs nothing while the client API is off). +func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) { + // Unconfigured means absent, the same stance /admin takes: a deployment that never + // set a token must not expose an endpoint that writes to what every TV displays. + if s.cfg.RadarrWebhookToken == "" { + http.NotFound(w, r) + return + } + if subtle.ConstantTimeCompare( + []byte(webhookToken(r)), []byte(s.cfg.RadarrWebhookToken), + ) != 1 { + writeError(w, http.StatusUnauthorized, "invalid webhook token") + return + } + + var payload radarrWebhookPayload + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid webhook payload") + return + } + + // Radarr's "Test" button posts a stub payload. Answering 200 without announcing a + // film that does not exist is what makes the test button mean "reachable". + if strings.EqualFold(payload.EventType, "Test") { + s.log.Info("radarr webhook test received") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true}) + return + } + + alert, ok := radarrImportAlert(payload, time.Now().UTC()) + if !ok { + // A grab, a rename, a health check or an upgrade of something already in the + // library: all real events, none of them "a new film is here". + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) + return + } + if s.cfg.RadarrAlertWindow <= 0 { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) + return + } + + s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow) + s.log.Info("radarr import announced", + "movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true}) +} + +// webhookToken accepts the shared secret three ways because Radarr's webhook settings +// differ by version: a header where custom headers exist, basic auth where they do not, +// and a query parameter as the form that always works. +func webhookToken(r *http.Request) string { + if token := strings.TrimSpace(r.Header.Get("X-Memby-Token")); token != "" { + return token + } + if token := bearerToken(r); token != "" { + return token + } + if _, password, ok := r.BasicAuth(); ok && password != "" { + return password + } + return strings.TrimSpace(r.URL.Query().Get("token")) +} + +// radarrImportAlert turns an import notification into the banner a TV shows, or reports +// that this event is not worth announcing. +// +// An upgrade is deliberately silent: the film was already there, and "new movie added" +// would be a lie about a file that was replaced with a better copy. +func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) { + if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade { + return clientAlert{}, false + } + title := strings.TrimSpace(payload.Movie.Title) + if title == "" || payload.Movie.ID <= 0 { + return clientAlert{}, false + } + + // Keyed on the file, so a title deleted and re-imported is news again while a + // repeated delivery of the same import is not. Clients dedupe on this id forever. + id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID) + if payload.MovieFile.ID <= 0 { + id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix()) + } + + name := title + if payload.Movie.Year > 0 { + name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year) + } + return clientAlert{ + ID: id, + Kind: alertKindRadarrImport, + Label: "NEW MOVIE ADDED", + Title: name, + Message: fmt.Sprintf("%s will be available in Emby shortly.", title), + // The image proxy already serves Radarr covers under this id and tag, so the + // banner shows the poster before Emby has finished scanning the film in. + ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID), + ImageTag: "radarr", + AiredAt: now.UTC().Format(time.RFC3339), + }, true +} + +// isRadarrImportEvent matches the event Radarr fires once a downloaded file has been +// imported into the library. The name has moved between versions, so both are accepted. +func isRadarrImportEvent(eventType string) bool { + switch strings.ToLower(strings.TrimSpace(eventType)) { + case "download", "moviefileimported": + return true + default: + return false + } +} diff --git a/server/internal/api/radarr_alerts_test.go b/server/internal/api/radarr_alerts_test.go new file mode 100644 index 0000000..bd5ea67 --- /dev/null +++ b/server/internal/api/radarr_alerts_test.go @@ -0,0 +1,278 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/config" +) + +func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload { + var payload radarrWebhookPayload + payload.EventType = "Download" + payload.Movie.ID = movieID + payload.Movie.Title = title + payload.Movie.Year = year + payload.MovieFile.ID = fileID + return payload +} + +func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + + alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now) + if !ok { + t.Fatal("expected an import to be announced") + } + if alert.ID != "radarr:412:file:9001" { + t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID) + } + if alert.Kind != alertKindRadarrImport { + t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport) + } + if alert.Label == "" { + t.Error("want a label: the app cannot know the wording for a kind it predates") + } + if alert.Title != "Mr. Smith Goes to Washington (1939)" { + t.Errorf("title = %q, want the year alongside it", alert.Title) + } + if !strings.Contains(alert.Message, "available in Emby shortly") { + t.Errorf("message = %q, want it to say the film is on its way", alert.Message) + } + // The image proxy serves Radarr covers under this pair, so the banner has a poster + // before Emby has scanned the film in. + if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" { + t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag) + } + if alert.AiredAt != now.Format(time.RFC3339) { + t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt) + } +} + +func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + + upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939) + upgrade.IsUpgrade = true + + grab := importPayload(413, 0, "Some Film", 2024) + grab.EventType = "Grab" + + untitled := importPayload(414, 9003, " ", 2024) + + unknownMovie := importPayload(0, 9004, "No Id", 2024) + + for name, payload := range map[string]radarrWebhookPayload{ + "quality upgrade of a film already there": upgrade, + "grabbed but not imported": grab, + "no title": untitled, + "no movie id": unknownMovie, + } { + if _, ok := radarrImportAlert(payload, now); ok { + t.Errorf("%s: expected no alert", name) + } + } +} + +func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + payload := importPayload(415, 9005, "Rear Window", 1954) + payload.EventType = "MovieFileImported" + + if _, ok := radarrImportAlert(payload, now); !ok { + t.Error("expected the alternate import event name to be announced") + } +} + +// A file id is what makes a repeated notification the same news; without one the id +// falls back to the clock so a re-import is not silently swallowed. +func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now) + if !ok { + t.Fatal("expected an alert") + } + if !strings.HasPrefix(alert.ID, "radarr:416:imported:") { + t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID) + } +} + +func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + existing := []storedAlert{ + { + Alert: clientAlert{ID: "radarr:1:file:1", AiredAt: now.Add(-4 * time.Hour).Format(time.RFC3339)}, + ExpiresAt: now.Add(-time.Minute), + }, + { + Alert: clientAlert{ID: "radarr:2:file:2", AiredAt: now.Add(-time.Hour).Format(time.RFC3339)}, + ExpiresAt: now.Add(2 * time.Hour), + }, + { + Alert: clientAlert{ID: "radarr:3:file:3", AiredAt: now.Add(-2 * time.Hour).Format(time.RFC3339)}, + ExpiresAt: now.Add(time.Hour), + }, + } + // Radarr delivering the same import twice must not stack two banners. + repeat := clientAlert{ID: "radarr:3:file:3", AiredAt: now.Format(time.RFC3339)} + + stored := appendAlert(existing, repeat, now.Add(3*time.Hour), now) + + if len(stored) != 2 { + t.Fatalf("expected 2 stored alerts, got %d: %+v", len(stored), stored) + } + if stored[0].Alert.ID != "radarr:3:file:3" { + t.Errorf("newest first: got %q", stored[0].Alert.ID) + } + if stored[1].Alert.ID != "radarr:2:file:2" { + t.Errorf("expected the unexpired older alert to survive, got %q", stored[1].Alert.ID) + } +} + +func TestAppendAlertKeepsOnlyTheNewestFew(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + var stored []storedAlert + // A bulk import: many films land at once and every one of them is current. + for i := 0; i < maxStoredAlerts+5; i++ { + alert := clientAlert{ + ID: "radarr:" + time.Duration(i).String(), + AiredAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + } + stored = appendAlert(stored, alert, now.Add(3*time.Hour), now) + } + if len(stored) != maxStoredAlerts { + t.Fatalf("stored %d alerts, want a cap of %d", len(stored), maxStoredAlerts) + } +} + +func TestLiveAlertsDropsClosedWindows(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + alerts := liveAlerts([]storedAlert{ + {Alert: clientAlert{ID: "current"}, ExpiresAt: now.Add(time.Minute)}, + {Alert: clientAlert{ID: "stale"}, ExpiresAt: now.Add(-time.Second)}, + }, now) + + if len(alerts) != 1 || alerts[0].ID != "current" { + t.Fatalf("expected only the current alert, got %+v", alerts) + } +} + +func TestMergeAlertsOrdersNewestFirstAcrossSources(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + at := func(d time.Duration) string { return now.Add(d).Format(time.RFC3339) } + + merged := mergeAlerts( + []clientAlert{{ID: "movie-recent", AiredAt: at(-10 * time.Minute)}}, + []clientAlert{ + {ID: "episode-older", AiredAt: at(-2 * time.Hour)}, + {ID: "episode-newest", AiredAt: at(-time.Minute)}, + }, + ) + + want := []string{"episode-newest", "movie-recent", "episode-older"} + for i, id := range want { + if merged[i].ID != id { + t.Fatalf("merged[%d] = %q, want %q (%+v)", i, merged[i].ID, id, merged) + } + } +} + +func TestMergeAlertsCapsWhatOneTVIsShown(t *testing.T) { + now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) + var many []clientAlert + for i := 0; i < maxAlerts+3; i++ { + many = append(many, clientAlert{ + ID: "alert-" + time.Duration(i).String(), + AiredAt: now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339), + }) + } + if got := len(mergeAlerts(many)); got != maxAlerts { + t.Fatalf("merged %d alerts, want a cap of %d", got, maxAlerts) + } +} + +func TestWebhookTokenIsReadFromEveryFormRadarrCanSend(t *testing.T) { + for name, build := range map[string]func() *http.Request{ + "header": func() *http.Request { + r := newWebhookRequest("/hooks/radarr") + r.Header.Set("X-Memby-Token", "secret") + return r + }, + "bearer": func() *http.Request { + r := newWebhookRequest("/hooks/radarr") + r.Header.Set("Authorization", "Bearer secret") + return r + }, + "basic auth": func() *http.Request { + r := newWebhookRequest("/hooks/radarr") + r.SetBasicAuth("memby", "secret") + return r + }, + "query": func() *http.Request { + return newWebhookRequest("/hooks/radarr?token=secret") + }, + } { + if got := webhookToken(build()); got != "secret" { + t.Errorf("%s: token = %q, want %q", name, got, "secret") + } + } +} + +func newWebhookRequest(target string) *http.Request { + return httptest.NewRequest(http.MethodPost, target, strings.NewReader("{}")) +} + +func TestRadarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) { + s := &Server{cfg: config.Config{}, log: discardLogger()} + rec := httptest.NewRecorder() + + s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=anything")) + + if rec.Code != http.StatusNotFound { + t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code) + } +} + +func TestRadarrWebhookRejectsAWrongToken(t *testing.T) { + s := &Server{ + cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour}, + log: discardLogger(), + } + rec := httptest.NewRecorder() + + s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=guess")) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", rec.Code) + } +} + +// Radarr's Test button has to succeed without putting a film that does not exist on +// every television in the house. +func TestRadarrWebhookTestEventAnnouncesNothing(t *testing.T) { + s := &Server{ + cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour}, + log: discardLogger(), + } + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, "/hooks/radarr?token=hook-secret", + strings.NewReader(`{"eventType":"Test","movie":{"id":1,"title":"Test Title"}}`), + ) + + // A nil cache would panic if this reached the store, which is the assertion. + s.handleRadarrWebhook(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/server/internal/api/radarr_test.go b/server/internal/api/radarr_test.go new file mode 100644 index 0000000..3666f53 --- /dev/null +++ b/server/internal/api/radarr_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/radarr" +) + +func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) { + location := time.FixedZone("NZST", 12*60*60) + now := time.Date(2026, 7, 30, 9, 0, 0, 0, location) + digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC() + digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC() + outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC() + theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC() + oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC() + modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC() + + row, err := buildRadarrRow([]radarr.Movie{ + {ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true}, + {ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true}, + {ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true}, + {ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true}, + {ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true}, + }, now, location) + if err != nil { + t.Fatal(err) + } + if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" || + row.Title != "Upcoming Movie releases" || len(row.Items) != 3 { + t.Fatalf("unexpected row: %+v", row) + } + var first, second radarrScheduleItem + if err := json.Unmarshal(row.Items[0], &first); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(row.Items[1], &second); err != nil { + t.Fatal(err) + } + if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" { + t.Fatalf("unexpected first item: %+v", first) + } + if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" || + second.MembyAvailabilityText != "Estimated digital release" { + t.Fatalf("unexpected second item: %+v", second) + } + var third radarrScheduleItem + if err := json.Unmarshal(row.Items[2], &third); err != nil { + t.Fatal(err) + } + if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" { + t.Fatalf("unexpected third item: %+v", third) + } +} + +func TestRadarrScheduleItemIncludesArtworkAndDownloadState(t *testing.T) { + location := time.FixedZone("NZST", 12*60*60) + release := time.Date(2026, 8, 1, 0, 0, 0, 0, location).UTC() + added := time.Date(2026, 7, 31, 22, 15, 0, 0, time.UTC) + movie := radarr.Movie{ + ID: 9, Title: "Arrival", DigitalRelease: &release, HasFile: true, Monitored: true, + MovieFile: &radarr.MovieFile{DateAdded: &added}, + Images: []radarr.Image{{CoverType: "poster"}, {CoverType: "fanart"}}, + } + effective, ok := effectiveRadarrRelease(movie) + if !ok { + t.Fatal("expected a release date") + } + item := toRadarrScheduleItem(movie, effective, time.Date(2026, 7, 30, 9, 0, 0, 0, location), location) + + if item.MembySource != "radarr" || item.MembyPlayable || + item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 { + t.Fatalf("unexpected synthetic item: %+v", item) + } + if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 10:15 AM" { + t.Fatalf("unexpected availability: %+v", item) + } +} diff --git a/server/internal/api/ranking.go b/server/internal/api/ranking.go new file mode 100644 index 0000000..6a85834 --- /dev/null +++ b/server/internal/api/ranking.go @@ -0,0 +1,553 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +func recommendationRow(id string) bool { + return id == "recommended" || + strings.HasPrefix(id, "for-you:") || + strings.HasPrefix(id, "curated:") || + strings.HasPrefix(id, "similar:") +} + +// filterRecommendationPermissions makes Emby, using this viewer's token, the final +// eligibility authority. The shared imported catalogue can suggest candidates but can +// never broaden library access or bypass parental controls. +func (s *Server) filterRecommendationPermissions( + ctx context.Context, + sess store.Session, + rows []recommend.Row, +) []recommend.Row { + ids := []string{} + for _, row := range rows { + if !recommendationRow(row.ID) { + continue + } + for _, item := range recommend.Decode(row.Items) { + ids = append(ids, item.ID) + } + } + if len(ids) == 0 { + return rows + } + allowed := map[string]bool{} + cred := credentials(sess) + for start := 0; start < len(ids); start += 100 { + end := min(start+100, len(ids)) + result, err := s.emby.Items(ctx, cred, rowParams(url.Values{ + "Ids": {strings.Join(ids[start:end], ",")}, + "Recursive": {"true"}, + "IncludeItemTypes": {"Movie,Series"}, + "Limit": {itoa(end - start)}, + }, fieldsRow)) + if err != nil { + s.log.Warn("recommendation permission check failed; hiding candidates", + "user", sess.EmbyUserID, "error", err) + for index := range rows { + if recommendationRow(rows[index].ID) { + rows[index].Items = []json.RawMessage{} + } + } + return rows + } + for _, raw := range result.Items { + decoded := recommend.Decode([]json.RawMessage{raw}) + if len(decoded) == 1 { + allowed[decoded[0].ID] = true + } + } + } + for index := range rows { + if !recommendationRow(rows[index].ID) { + continue + } + filtered := []json.RawMessage{} + for _, item := range recommend.Decode(rows[index].Items) { + if allowed[item.ID] { + filtered = append(filtered, item.Raw) + } + } + rows[index].Items = filtered + } + return rows +} + +func (s *Server) rankingContext( + ctx context.Context, + userID string, +) (recommend.WeightedProfile, map[string]recommend.ItemExposure, map[string]float64) { + profile := recommend.WeightedProfile{} + if s.store == nil { + return profile, nil, nil + } + if raw, err := s.store.WeightedRecommendationProfile(ctx, userID); err == nil { + _ = json.Unmarshal(raw, &profile) + } else { + s.log.Warn("weighted profile unavailable", "user", userID, "error", err) + } + if profile.ExplicitPositive == nil { + profile.ExplicitPositive = map[string]bool{} + } + if profile.ExplicitNegative == nil { + profile.ExplicitNegative = map[string]bool{} + } + if raw, err := s.store.RecommendationOnboarding(ctx, userID); err == nil { + var preferences recommend.OnboardingPreferences + if json.Unmarshal(raw, &preferences) == nil { + profile.ApplyOnboarding(preferences, s.weightedConfig().MinimumEvidence) + ids := make([]string, 0, len(preferences.Ratings)) + for id, rating := range preferences.Ratings { + if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 { + ids = append(ids, id) + } + } + if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil { + for _, item := range recommend.Decode(raws) { + profile.ApplyOnboardingRating( + item, preferences.Ratings[item.ID], + s.weightedConfig().MinimumEvidence, + ) + } + } + } + } + if actions, err := s.store.RecommendationActions(ctx, userID); err == nil { + ids := make([]string, 0, len(actions)) + byID := make(map[string]string, len(actions)) + for _, action := range actions { + ids = append(ids, action.ItemID) + byID[action.ItemID] = action.Action + switch action.Action { + case "more_like_this": + profile.ExplicitPositive[action.ItemID] = true + case "not_for_me": + profile.ExplicitNegative[action.ItemID] = true + } + } + if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil { + for _, item := range recommend.Decode(raws) { + profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this") + } + } + } + exposures := map[string]recommend.ItemExposure{} + if values, err := s.store.UserItemExposures( + ctx, userID, time.Now().Add(-45*24*time.Hour), + ); err == nil { + for _, value := range values { + exposures[value.ItemID] = recommend.ItemExposure{ + Impressions: value.Impressions, Focuses: value.Focuses, + Selects: value.Selects, LastShown: value.LastShown, + } + } + } + household, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour)) + if err != nil { + household = map[string]float64{} + } + return profile, exposures, household +} + +func (s *Server) personalizeTitles( + ctx context.Context, + sess store.Session, + rows []recommend.Row, +) []recommend.Row { + if len(rows) == 0 { + return rows + } + profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) + cfg := s.weightedConfig() + now := time.Now() + location := s.cfg.SonarrLocation + for index := range rows { + row := &rows[index] + compatibility := map[string]float64{} + for _, raw := range row.Items { + var marker struct { + ID string `json:"Id"` + Compatibility string `json:"MembyCompatibility"` + } + if json.Unmarshal(raw, &marker) == nil { + switch { + case strings.Contains(strings.ToLower(marker.Compatibility), "direct"): + compatibility[marker.ID] = 1 + case strings.Contains(strings.ToLower(marker.Compatibility), "transcod"): + compatibility[marker.ID] = -1 + } + } + } + intent := recommend.RankIntent{ + ID: row.ID, Now: now, Location: location, HouseholdScores: household, + Compatibility: compatibility, + } + switch { + case row.ID == "latest-movies": + intent.NewReleasesOnly = true + case strings.Contains(row.ID, "one-episode"), strings.Contains(row.ID, "late-night"): + intent.PreferShort = true + intent.MaxRuntimeMins = 60 + case strings.Contains(row.ID, "hidden"): + intent.HiddenLibrary = true + intent.UnseenOnly = true + } + ranked := recommend.WeightedRank( + profile, recommend.Decode(row.Items), exposures, intent, cfg, len(row.Items), + ) + items := make([]json.RawMessage, 0, len(ranked)) + for _, value := range ranked { + items = append(items, recommend.EnrichRankedItem(value)) + } + // Mandatory progress rows must remain useful even before a profile is prepared. + if len(items) > 0 || row.ID != "continue" && row.ID != "next-up" { + row.Items = items + } + } + return rows +} + +func (s *Server) personalizeSearch( + ctx context.Context, + sess store.Session, + term string, + raws []json.RawMessage, + limit int, +) []json.RawMessage { + profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) + items := recommend.Decode(raws) + relevance := make(map[string]float64, len(items)) + wanted := strings.ToLower(strings.TrimSpace(term)) + for _, item := range items { + name := strings.ToLower(strings.TrimSpace(item.Name)) + switch { + case name == wanted: + relevance[item.ID] = 20 + case strings.HasPrefix(name, wanted): + relevance[item.ID] = 12 + case strings.Contains(name, wanted): + relevance[item.ID] = 8 + default: + relevance[item.ID] = 4 + } + } + ranked := recommend.WeightedRank(profile, items, exposures, recommend.RankIntent{ + ID: "search", Now: time.Now(), Location: s.cfg.SonarrLocation, + SearchRelevance: relevance, HouseholdScores: household, + }, s.weightedConfig(), limit) + out := make([]json.RawMessage, 0, len(ranked)) + for _, value := range ranked { + out = append(out, recommend.EnrichRankedItem(value)) + } + return out +} + +func (s *Server) weightedConfig() recommend.WeightedConfig { + cfg := recommend.DefaultWeightedConfig() + if s.cfg.RecommendationWeights != "" { + _ = json.Unmarshal([]byte(s.cfg.RecommendationWeights), &cfg) + } + return cfg +} + +// deduplicateRows gives the earliest row ownership of a title. Continue Watching and +// Next Up keep their landmarks; later discovery shelves fill with their remaining +// unique posters. +func deduplicateRows(rows []recommend.Row) []recommend.Row { + seen := map[string]bool{} + for rowIndex := range rows { + items := recommend.Decode(rows[rowIndex].Items) + filtered := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + key := item.ID + if item.SeriesID != "" { + key = item.SeriesID + } + if key == "" || seen[key] { + continue + } + seen[key] = true + filtered = append(filtered, item.Raw) + } + rows[rowIndex].Items = filtered + } + return rows +} + +// personalizeRowsByTitleScores uses the same title scores to order discovery shelves. +// Mandatory shelves receive stable anchors; all other rows compete on the average of +// their leading posters, which makes row ordering change with the same profile evidence +// that changes poster ordering. +func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row { + type scoredRow struct { + row recommend.Row + score float64 + position int + } + ranked := make([]scoredRow, 0, len(rows)) + for position, row := range rows { + score := 0.0 + count := 0 + for _, raw := range row.Items { + var payload struct { + Score float64 `json:"MembyRecommendationScore"` + } + if json.Unmarshal(raw, &payload) == nil { + score += payload.Score + count++ + } + if count == 6 { + break + } + } + if count > 0 { + score /= float64(count) + } + // A small authored-position prior avoids reshuffling ties and cold starts. + score += 0.05 / float64(position+1) + switch row.ID { + case "continue": + score = 1_000 + case "next-up": + score = 100 + case "latest-movies": + score = 90 + } + ranked = append(ranked, scoredRow{row: row, score: score, position: position}) + } + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].score != ranked[j].score { + return ranked[i].score > ranked[j].score + } + return ranked[i].position < ranked[j].position + }) + out := make([]recommend.Row, 0, len(ranked)) + for _, value := range ranked { + out = append(out, value.row) + } + return out +} + +func selectPersonalizedRows(rows []recommend.Row) []recommend.Row { + out := make([]recommend.Row, 0, len(rows)) + for _, row := range rows { + switch row.ID { + case "continue", "next-up", "latest-movies", "favorites": + out = append(out, row) + continue + } + if len(row.Items) == 0 { + continue + } + total, count := 0.0, 0 + for _, raw := range row.Items { + var payload struct { + Score float64 `json:"MembyRecommendationScore"` + } + if json.Unmarshal(raw, &payload) == nil { + total += payload.Score + count++ + } + if count == 6 { + break + } + } + if count > 0 && total/float64(count) < -0.25 { + continue + } + out = append(out, row) + } + return out +} + +type recommendationActionRequest struct { + Action string `json:"action"` +} + +func (s *Server) handleRecommendationAction( + w http.ResponseWriter, + r *http.Request, + sess store.Session, +) { + itemID := strings.TrimSpace(r.PathValue("id")) + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + var err error + if r.Method == http.MethodDelete { + err = s.store.ClearRecommendationAction(r.Context(), sess.EmbyUserID, itemID) + } else { + var req recommendationActionRequest + if decodeErr := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); decodeErr != nil { + writeError(w, http.StatusBadRequest, "invalid recommendation action") + return + } + err = s.store.SetRecommendationAction( + r.Context(), sess.EmbyUserID, itemID, strings.TrimSpace(req.Action), + ) + } + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) + if s.forYou != nil { + s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) + s.forYou.RefreshAsync(sess, false) + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleRecommendationPreferences( + w http.ResponseWriter, + r *http.Request, + sess store.Session, +) { + if r.Method == http.MethodGet { + s.handleRecommendationPreferencesGet(w, r, sess) + return + } + var preferences recommend.OnboardingPreferences + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&preferences); err != nil { + writeError(w, http.StatusBadRequest, "invalid onboarding preferences") + return + } + if len(preferences.Ratings) > 40 { + writeError(w, http.StatusBadRequest, "too many onboarding ratings") + return + } + for id, rating := range preferences.Ratings { + if strings.TrimSpace(id) == "" || rating < 1 || rating > 5 { + writeError(w, http.StatusBadRequest, "ratings must be between 1 and 5") + return + } + } + preferences.Completed = true + raw, _ := json.Marshal(preferences) + if err := s.store.SetRecommendationOnboarding( + r.Context(), sess.EmbyUserID, raw, + ); err != nil { + writeError(w, http.StatusInternalServerError, "could not save onboarding preferences") + return + } + _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) + if s.forYou != nil { + s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) + s.forYou.RefreshAsync(sess, false) + } + w.WriteHeader(http.StatusNoContent) +} + +type recommendationOnboardingResponse struct { + Completed bool `json:"completed"` + Ratings map[string]int `json:"ratings"` + Items []json.RawMessage `json:"items"` +} + +func (s *Server) handleRecommendationPreferencesGet( + w http.ResponseWriter, + r *http.Request, + sess store.Session, +) { + preferences := recommend.OnboardingPreferences{} + if raw, err := s.store.RecommendationOnboarding(r.Context(), sess.EmbyUserID); err == nil { + _ = json.Unmarshal(raw, &preferences) + } + if preferences.Ratings == nil { + preferences.Ratings = map[string]int{} + } + if preferences.Completed { + writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ + Completed: true, Ratings: preferences.Ratings, Items: []json.RawMessage{}, + }) + return + } + raws, err := s.store.AllRecommendationCandidates(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load rating choices") + return + } + candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 24) + row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"} + for _, item := range candidates { + row.Items = append(row.Items, item.Raw) + } + filtered := s.filterRecommendationPermissions( + r.Context(), sess, []recommend.Row{row}, + ) + items := []json.RawMessage{} + if len(filtered) == 1 { + items = filtered[0].Items + if len(items) > 16 { + items = items[:16] + } + } + writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ + Completed: preferences.Completed, + Ratings: preferences.Ratings, + Items: items, + }) +} + +// recommendationOnboardingCandidates selects recognisable, well-rated titles while +// keeping movies, series and primary genres mixed. It is deterministic so returning to +// an unfinished onboarding screen does not reshuffle the choices. +func recommendationOnboardingCandidates(items []recommend.Item, limit int) []recommend.Item { + sort.SliceStable(items, func(i, j int) bool { + if items[i].CommunityRating != items[j].CommunityRating { + return items[i].CommunityRating > items[j].CommunityRating + } + if items[i].ProductionYear != items[j].ProductionYear { + return items[i].ProductionYear > items[j].ProductionYear + } + return items[i].Name < items[j].Name + }) + buckets := map[string][]recommend.Item{"movie": {}, "series": {}} + typeCounts := map[string]int{} + genreCounts := map[string]int{} + perType := max(1, limit/2) + for _, item := range items { + kind := strings.ToLower(strings.TrimSpace(item.Type)) + if kind != "movie" && kind != "series" || item.CommunityRating <= 0 || + typeCounts[kind] >= perType { + continue + } + genre := "" + if len(item.Genres) > 0 { + genre = strings.ToLower(strings.TrimSpace(item.Genres[0])) + } + if genre != "" && genreCounts[genre] >= 3 { + continue + } + buckets[kind] = append(buckets[kind], item) + typeCounts[kind]++ + genreCounts[genre]++ + if typeCounts["movie"]+typeCounts["series"] == limit { + break + } + } + out := make([]recommend.Item, 0, limit) + for index := 0; len(out) < limit; index++ { + added := false + for _, kind := range []string{"movie", "series"} { + if index < len(buckets[kind]) { + out = append(out, buckets[kind][index]) + added = true + } + } + if !added { + break + } + } + return out +} diff --git a/server/internal/api/recommend.go b/server/internal/api/recommend.go index ec95aaf..2668f6d 100644 --- a/server/internal/api/recommend.go +++ b/server/internal/api/recommend.go @@ -56,7 +56,7 @@ func (s *Server) cachedRecommendations(ctx context.Context, userID string) []rec // buildRecommendations computes and caches rows for one user. func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) ([]recommend.Row, error) { - rows, err := s.recommender.BuildRows(ctx, credentials(sess)) + rows, err := s.recommender.BuildRowsForUser(ctx, credentials(sess), sess.Username) if err != nil { return nil, err } @@ -135,6 +135,9 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store s.log.Warn("prepared For You read failed; using live fallback", "user", sess.EmbyUserID, "error", err) } else if hit { + rows = s.filterRecommendationPermissions(r.Context(), sess, rows) + rows = s.personalizeTitles(r.Context(), sess, rows) + rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows))) w.Header().Set("X-Memby-For-You", "prepared") if stale { s.forYou.MarkDirty(r.Context(), sess) @@ -160,6 +163,9 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store s.writeUpstreamError(w, err, "could not build For You recommendations") return } + rows = s.filterRecommendationPermissions(r.Context(), sess, rows) + rows = s.personalizeTitles(r.Context(), sess, rows) + rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows))) w.Header().Set("X-Memby-For-You", "live-fallback") writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)}) } diff --git a/server/internal/api/related.go b/server/internal/api/related.go new file mode 100644 index 0000000..26fff3c --- /dev/null +++ b/server/internal/api/related.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// fieldsRelated adds Studios to the detail set: the explanation layer names the studio a +// viewer keeps returning to, and Emby omits it unless asked. +const fieldsRelated = fieldsDetail + ",Studios" + +// relatedResponse is what the detail page renders: a strip of short reasons under the +// description, and the carousel beneath the page. +type relatedResponse struct { + Reasons []string `json:"reasons"` + Items []json.RawMessage `json:"items"` +} + +// handleRelated explains one title to one viewer and lists what resembles it. +// +// Building the taste profile costs the same Emby fan-out the home rows pay for, so the +// whole answer is cached per user and item. It is deliberately *not* folded into +// `/v1/items/{id}`: that response is shared with the screensaver and the player, and this +// one is only ever needed once a detail page is open. +func (s *Server) handleRelated(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 + } + key := cache.UserKey(sess.EmbyUserID, "related:v1:"+itemID) + if raw, err := s.cache.Get(ctx, key); err == nil { + w.Header().Set("X-Memby-Cache", "hit") + writeRaw(w, http.StatusOK, raw) + return + } + + raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated) + if err != nil { + s.writeUpstreamError(w, err, "could not load the item") + return + } + decoded := recommend.Decode([]json.RawMessage{raw}) + if len(decoded) == 0 { + writeError(w, http.StatusNotFound, "item not found") + return + } + + reasons, related, err := s.recommender.RelatedTo( + ctx, credentials(sess), decoded[0], relatedRowSize, + ) + if err != nil { + s.writeUpstreamError(w, err, "could not load related titles") + return + } + + body, err := json.Marshal(relatedResponse{ + Reasons: nonNilStrings(reasons), + Items: nonNilRaws(recommend.Raws(related)), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not encode related titles") + return + } + if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil { + s.log.Warn("related cache write failed", "error", err) + } + w.Header().Set("X-Memby-Cache", "miss") + writeRaw(w, http.StatusOK, body) +} + +// relatedRowSize is a carousel's worth. The strip scrolls, but a viewer who reaches the +// twelfth card has stopped looking for something like this one. +const relatedRowSize = 12 + +func nonNilRaws(values []json.RawMessage) []json.RawMessage { + if values == nil { + return []json.RawMessage{} + } + return values +} diff --git a/server/internal/api/related_test.go b/server/internal/api/related_test.go new file mode 100644 index 0000000..943db28 --- /dev/null +++ b/server/internal/api/related_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" +) + +// The TV decodes this into GatewayRelated. Both field names and the empty-value encoding +// are part of the contract: kotlinx.serialization would reject a null where it expects a +// list, so a title with no reasons must still send `[]`. +func TestRelatedResponseWireShape(t *testing.T) { + raw, err := json.Marshal(relatedResponse{ + Reasons: nonNilStrings(nil), + Items: nonNilRaws(nil), + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(raw); got != `{"reasons":[],"items":[]}` { + t.Fatalf("empty response encoded as %s", got) + } + + raw, err = json.Marshal(relatedResponse{ + Reasons: []string{"Because you watch Thriller"}, + Items: []json.RawMessage{json.RawMessage(`{"Id":"1","Name":"Sicario"}`)}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Items are Emby's item JSON forwarded verbatim, exactly as every other row is. + if !strings.Contains(string(raw), `"items":[{"Id":"1","Name":"Sicario"}]`) { + t.Fatalf("item payload was not forwarded verbatim: %s", raw) + } +} + +func TestRelatedFieldsAskEmbyForStudios(t *testing.T) { + // Why() names the studio a viewer keeps returning to, and Emby omits Studios unless + // the request asks for it. + if !strings.Contains(fieldsRelated, "Studios") { + t.Fatalf("fieldsRelated must request Studios, got %q", fieldsRelated) + } + if !strings.Contains(fieldsRelated, "People") { + t.Fatalf("fieldsRelated must request People, got %q", fieldsRelated) + } +} diff --git a/server/internal/api/release.go b/server/internal/api/release.go index 9b2726a..3efe87c 100644 --- a/server/internal/api/release.go +++ b/server/internal/api/release.go @@ -1,8 +1,13 @@ package api import ( + "archive/zip" + "crypto/sha256" "crypto/subtle" + _ "embed" + "encoding/hex" "fmt" + "html/template" "io" "net/http" "os" @@ -15,6 +20,11 @@ import ( const maxReleaseSize = 250 << 20 +//go:embed install.html +var installPageSource string + +var installPage = template.Must(template.New("install").Parse(installPageSource)) + var ( releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`) @@ -51,6 +61,11 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "version must look like 0.1.54") return } + mandatory, validMandatory := parseMandatoryRelease(r.FormValue("mandatory")) + if !validMandatory { + writeError(w, http.StatusBadRequest, "mandatory must be true or false") + return + } current := s.updatePolicy.get() if current.LatestVersion != "" && @@ -80,35 +95,67 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { tempName := temp.Name() defer os.Remove(tempName) - written, copyErr := io.Copy(temp, source) + digest := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(temp, digest), source) + syncErr := temp.Sync() closeErr := temp.Close() if copyErr != nil || closeErr != nil || written < 4 { writeError(w, http.StatusBadRequest, "could not store APK") return } - - // APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages - // before they become an update every TV is invited to install. - stored, err := os.Open(tempName) - if err != nil { - writeError(w, http.StatusInternalServerError, "could not verify APK") + if syncErr != nil { + writeError(w, http.StatusInternalServerError, "could not safely store APK") return } - var magic [4]byte - _, readErr := io.ReadFull(stored, magic[:]) - stored.Close() - if readErr != nil || string(magic[:2]) != "PK" { + actualSHA256 := hex.EncodeToString(digest.Sum(nil)) + if expected := strings.ToLower(strings.TrimSpace(r.FormValue("sha256"))); expected != "" && + (expected != actualSHA256 || len(expected) != sha256.Size*2) { + writeError(w, http.StatusBadRequest, "APK checksum does not match") + return + } + + // Parse the archive, rather than checking only its first two bytes. AndroidManifest.xml + // is compulsory in an APK; this rejects truncated ZIPs and renamed logs/HTML. + archive, err := zip.OpenReader(tempName) + if err != nil { writeError(w, http.StatusBadRequest, "uploaded file is not an APK") return } + hasManifest := false + for _, entry := range archive.File { + if entry.Name == "AndroidManifest.xml" { + hasManifest = true + break + } + } + archive.Close() + if !hasManifest { + writeError(w, http.StatusBadRequest, "uploaded APK has no Android manifest") + return + } filename := fmt.Sprintf("memby-%s.apk", version) destination := filepath.Join(s.cfg.ReleaseDir, filename) - if err := os.Rename(tempName, destination); err != nil { - s.log.Error("release publish rename failed", "error", err) - writeError(w, http.StatusInternalServerError, "could not publish APK") + newFile := true + if existingSHA256, hashErr := fileSHA256(destination); hashErr == nil { + if existingSHA256 != actualSHA256 { + writeError(w, http.StatusConflict, + "that version already exists with different APK contents; publish a new version") + return + } + newFile = false + } else if !os.IsNotExist(hashErr) { + s.log.Error("existing release could not be verified", "error", hashErr) + writeError(w, http.StatusInternalServerError, "could not verify existing release") return } + if newFile { + if err := os.Rename(tempName, destination); err != nil { + s.log.Error("release publish rename failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not publish APK") + return + } + } if err := os.Chmod(destination, 0o640); err != nil { s.log.Warn("release permissions could not be tightened", "error", err) } @@ -117,10 +164,20 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { Enabled: true, LatestVersion: version, MinimumVersion: current.MinimumVersion, - DownloadURL: s.cfg.PublicURL + "/updates/" + filename, + DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename), + SHA256: actualSHA256, + SizeBytes: written, Notes: strings.TrimSpace(r.FormValue("notes")), } + if mandatory { + // Setting the floor to the release being published makes every older client + // receive a mandatory verdict, which has no dismiss/skip path on the TV. + policy.MinimumVersion = version + } if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { + if newFile { + _ = os.Remove(destination) + } s.log.Error("release policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved") return @@ -131,19 +188,181 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) { return } - s.log.Info("release published", "version", version, "bytes", written, "file", filename) + s.log.Info("release published", "version", version, "mandatory", mandatory, + "bytes", written, "file", filename) writeJSON(w, http.StatusCreated, s.updatePolicy.get()) } -// handleReleaseDownload serves immutable, signed APKs. They carry no household secrets, -// so downloads do not need a TV session and continue working through Android's installer. -func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) { - filename := r.PathValue("filename") - if !releaseFilenamePattern.MatchString(filename) { +func parseMandatoryRelease(value string) (mandatory, valid bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "0", "false": + return false, true + case "1", "true": + return true, true + default: + return false, false + } +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +type installPageData struct { + Authenticated bool + Ready bool + Version string + Notes string + DownloadURL string + Size string + Error string + LoginNext string +} + +func preventDiscovery(w http.ResponseWriter) { + // These cover general search engines, crawler-specific implementations and caches. + // They are intentionally also applied to APK responses so a discovered download URL + // does not appear as a searchable binary result. + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet, noimageindex") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") +} + +func handleRobots(w http.ResponseWriter, _ *http.Request) { + preventDiscovery(w) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = io.WriteString(w, "User-agent: *\nDisallow: /\n") +} + +// handleInstallPage is deliberately public: it is the bootstrap path for a television +// that does not have Memby yet. It exposes only the signed APK and operator-authored +// release notes, never household or Emby data. +func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) { + s.renderInstallPage(w, r, "", http.StatusOK) +} + +func (s *Server) renderInstallPage( + w http.ResponseWriter, + r *http.Request, + message string, + status int, +) { + if len(s.installerSecret()) == 0 { http.NotFound(w, r) return } + authenticated := s.validInstallerSession(r) + if !authenticated { + s.renderAccessLogin(w, r, message, status, "/install") + return + } + policy := s.updatePolicy.get() + version := strings.TrimSpace(policy.LatestVersion) + filename := fmt.Sprintf("memby-%s.apk", version) + info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)) + ready := authenticated && releaseVersionPattern.MatchString(version) && + err == nil && !info.IsDir() + + data := installPageData{ + Authenticated: true, + Ready: ready, + Version: version, + Notes: strings.TrimSpace(policy.Notes), + Error: message, + } + if ready { + data.DownloadURL = "/updates/latest.apk" + data.Size = fmt.Sprintf("%.1f MB", float64(info.Size())/(1024*1024)) + } + + s.writeInstallPage(w, data, status) +} + +func (s *Server) renderAccessLogin( + w http.ResponseWriter, + r *http.Request, + message string, + status int, + next string, +) { + if len(s.installerSecret()) == 0 { + http.NotFound(w, r) + return + } + s.writeInstallPage(w, installPageData{ + Error: message, + LoginNext: cleanInstallerDestination(next), + }, status) +} + +func (s *Server) writeInstallPage(w http.ResponseWriter, data installPageData, status int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "+ + "base-uri 'none'; frame-ancestors 'none'") + w.Header().Set("Permissions-Policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()") + preventDiscovery(w) + if status == http.StatusOK && data.Authenticated && !data.Ready { + status = http.StatusServiceUnavailable + } + if status != http.StatusOK { + w.WriteHeader(status) + } + if err := installPage.Execute(w, data); err != nil && s.log != nil { + s.log.Error("install page render failed", "error", err) + } +} + +// handleLatestReleaseDownload gives first-time installers a stable address. Serve the +// package directly: some Android TV downloaders hand both sides of an HTTP redirect to +// the package installer, causing a successful install followed by a spurious parse error. +func (s *Server) handleLatestReleaseDownload(w http.ResponseWriter, r *http.Request) { + if !s.validInstallerSession(r) { + http.NotFound(w, r) + return + } + version := strings.TrimSpace(s.updatePolicy.get().LatestVersion) + if !releaseVersionPattern.MatchString(version) { + http.NotFound(w, r) + return + } + filename := fmt.Sprintf("memby-%s.apk", version) + if info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)); err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + preventDiscovery(w) w.Header().Set("Content-Type", "application/vnd.android.package-archive") - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Cache-Control", "private, no-store") + http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename)) +} + +// handleReleaseDownload serves immutable, signed APKs. Access requires either a short +// browser installer session or the signed release URL returned to an authenticated app. +func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) { + filename := r.PathValue("filename") + if !releaseFilenamePattern.MatchString(filename) || + !s.allowedReleaseDownload(r, filename) { + // A 404 does not confirm whether a guessed version exists. + http.NotFound(w, r) + return + } + preventDiscovery(w) + w.Header().Set("Content-Type", "application/vnd.android.package-archive") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.Header().Set("Cache-Control", "private, no-store") http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename)) } diff --git a/server/internal/api/release_test.go b/server/internal/api/release_test.go index 4d27c84..be62e11 100644 --- a/server/internal/api/release_test.go +++ b/server/internal/api/release_test.go @@ -1,13 +1,23 @@ package api import ( + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" + "sync/atomic" "testing" + "time" + "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/config" + "github.com/ponzischeme89/memby/server/internal/emby" ) func TestReleasePublishAuth(t *testing.T) { @@ -43,15 +53,25 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil { t.Fatal(err) } - s := &Server{cfg: config.Config{ReleaseDir: dir}} + s := &Server{cfg: config.Config{ + ReleaseDir: dir, ReleasePublishToken: "test-release-secret", + }} rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil) + req := httptest.NewRequest(http.MethodGet, + s.signedReleasePath("memby-0.1.54.apk"), nil) req.SetPathValue("filename", "memby-0.1.54.apk") s.handleReleaseDownload(rec, req) if rec.Code != http.StatusOK || rec.Body.String() != string(payload) { t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String()) } + if disposition := rec.Header().Get("Content-Disposition"); disposition != + `attachment; filename="memby-0.1.54.apk"` { + t.Fatalf("Content-Disposition = %q", disposition) + } + if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") { + t.Fatalf("APK crawler policy = %q", rec.Header().Get("X-Robots-Tag")) + } rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil) @@ -61,3 +81,285 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) { t.Fatalf("invalid filename got %d, want 404", rec.Code) } } + +func TestInstallPageAndLatestDownloadUsePublishedRelease(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil { + t.Fatal(err) + } + s := &Server{cfg: config.Config{ + ReleaseDir: dir, ReleasePublishToken: "test-release-secret", + }} + s.updatePolicy.set(appupdate.Policy{ + LatestVersion: "0.1.72", + Notes: ``, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/install", nil) + addInstallerSession(t, s, req) + s.handleInstallPage(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("install page status = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Download Memby 0.1.72") || + !strings.Contains(body, `href="/updates/latest.apk"`) { + t.Fatalf("install page is missing current release details: %s", body) + } + if strings.Contains(body, `