Publish current app and server
This commit is contained in:
+30
-5
@@ -16,15 +16,21 @@ MEMBY_PORT=32768
|
|||||||
|
|
||||||
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
||||||
MEMBY_LOG_LEVEL=INFO
|
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
|
MEMBY_TIMEZONE=Pacific/Auckland
|
||||||
|
|
||||||
# How long a cached home payload stays warm.
|
# How long a cached home payload stays warm.
|
||||||
MEMBY_HOME_TTL=60s
|
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.
|
# 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
|
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
|
||||||
# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with
|
# 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.
|
# Public gateway address and a dedicated token used only by the Gitea release workflow.
|
||||||
# Generate the token with: openssl rand -hex 32
|
# Generate the token with: openssl rand -hex 32
|
||||||
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
|
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.
|
# Library import. Hourly incremental keeps up with episodes added through the day.
|
||||||
MEMBY_SYNC_INTERVAL=1h
|
MEMBY_SYNC_INTERVAL=1h
|
||||||
@@ -55,6 +61,24 @@ MEMBY_SONARR_TTL=5m
|
|||||||
# 0 turns the banners off and leaves the airing-today row alone.
|
# 0 turns the banners off and leaves the airing-today row alone.
|
||||||
MEMBY_SONARR_ALERT_WINDOW=3h
|
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://<public gateway>/hooks/radarr?token=<this value>. 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
|
# 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.
|
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
|
||||||
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
|
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_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b
|
||||||
MEMBY_TRACEARR_SYNC_INTERVAL=5m
|
MEMBY_TRACEARR_SYNC_INTERVAL=5m
|
||||||
MEMBY_TRACEARR_FULL_INTERVAL=24h
|
MEMBY_TRACEARR_FULL_INTERVAL=24h
|
||||||
MEMBY_FOR_YOU_MIN_REBUILD_AGE=10m
|
MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h
|
||||||
MEMBY_FOR_YOU_REFRESH_INTERVAL=30m
|
MEMBY_FOR_YOU_REFRESH_INTERVAL=24h
|
||||||
|
MEMBY_FOR_YOU_REBUILD_HOUR=4
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ local.properties
|
|||||||
# Holds the Postgres password, admin token and Emby address.
|
# Holds the Postgres password, admin token and Emby address.
|
||||||
.env
|
.env
|
||||||
/server/bin/
|
/server/bin/
|
||||||
|
/server/.tmp-go-cache/
|
||||||
|
|
||||||
# Release artefacts and signing material. The keystore must never be committed:
|
# Release artefacts and signing material. The keystore must never be committed:
|
||||||
# whoever holds it can publish an update that installs over Memby.
|
# whoever holds it can publish an update that installs over Memby.
|
||||||
|
|||||||
@@ -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
|
`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
|
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).
|
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
|
Alerts come from two shapes of producer. **Derived**: `api/alerts.go` announces an episode
|
||||||
but which Emby has not imported yet ("aired, coming soon"). It reads the *cached*
|
whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming
|
||||||
airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve:
|
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
|
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
|
`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
|
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),
|
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;
|
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
|
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
|
**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.
|
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`
|
`/admin/api/status` every 5s. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin`
|
||||||
route 404s.
|
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
|
**Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring
|
||||||
(recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does
|
(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
|
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`).
|
`resumePositionMs`).
|
||||||
|
|
||||||
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
|
**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
|
token, userId) *and* mirrors the active profile into the flat top-level keys the rest of
|
||||||
the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep
|
the app reads. `switchProfile`/`saveSession` must keep both in sync; `legacyProfile()`
|
||||||
both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that
|
synthesises a profile from the flat keys for installs that predate the list. `deviceId` is
|
||||||
predate the list. `deviceId` is intentionally preserved across `clearSession()`.
|
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::<userId>@<serverUrl>` 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
|
**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
|
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
|
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.
|
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
|
**Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and
|
||||||
`ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so
|
`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.
|
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
|
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
||||||
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
|
tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting
|
||||||
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
|
the release variants the `androidx.baselineprofile` plugin generates, so its numbers are
|
||||||
debug-influenced.
|
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
|
## 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,
|
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.
|
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,
|
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
|
||||||
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
|
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.
|
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
|
||||||
|
|||||||
+216
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
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.
|
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
|
## Features
|
||||||
|
|
||||||
- **System screensaver** via `DreamService` — auto-starts on idle once selected in the
|
- **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
|
### Each release
|
||||||
|
|
||||||
```powershell
|
```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
|
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
|
index.html the page people are sent to
|
||||||
latest.json the update manifest the app polls
|
latest.json the update manifest the app polls
|
||||||
memby-0.1.54.apk the build
|
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`
|
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
|
## Notes & limitations
|
||||||
|
|
||||||
- Playback uses Emby's direct stream (`/Videos/{id}/stream?static=true`). This direct-plays
|
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
|
||||||
containers/codecs ExoPlayer supports (most MP4/H.264, many MKV). Server-side transcoding
|
Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC
|
||||||
is not requested; unusual codecs may need it — a future enhancement is to call Emby's
|
profiles, maximum levels and resolutions rather than assuming every decoder handles every
|
||||||
`PlaybackInfo` endpoint and use the returned HLS transcode URL (the `media3-exoplayer-hls`
|
file. Emby can preserve a supported video stream while converting only incompatible audio
|
||||||
dependency is already included).
|
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
|
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only
|
||||||
server this is unnecessary but harmless.
|
server this is unnecessary but harmless.
|
||||||
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
|
- 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.
|
||||||
```
|
```
|
||||||
|
|||||||
+42
-2
@@ -5,6 +5,7 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("androidx.baselineprofile")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
|
// 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.
|
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
||||||
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
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
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.1.69"
|
val defaultVersionName = "0.2.9"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -56,6 +78,9 @@ android {
|
|||||||
|
|
||||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.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
|
// Release signing. Android identifies an app by (applicationId, signing key), so
|
||||||
@@ -79,7 +104,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -155,6 +186,9 @@ dependencies {
|
|||||||
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
|
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
|
||||||
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
||||||
implementation("androidx.metrics:metrics-performance:1.0.0")
|
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)
|
// Compose (versions from BOM)
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
@@ -185,6 +219,12 @@ dependencies {
|
|||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
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")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
|
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
|
||||||
|
|||||||
Vendored
+43
-5
@@ -1,8 +1,46 @@
|
|||||||
# kotlinx.serialization keeps @Serializable metadata via generated serializers.
|
# R8 rules for release builds (isMinifyEnabled = true).
|
||||||
-keepattributes *Annotation*, InnerClasses
|
#
|
||||||
|
# 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.**
|
-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(...);
|
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
|
||||||
|
|||||||
@@ -74,8 +74,8 @@
|
|||||||
android:theme="@style/Theme.Memby.Fullscreen"
|
android:theme="@style/Theme.Memby.Fullscreen"
|
||||||
tools:ignore="DiscouragedApi" />
|
tools:ignore="DiscouragedApi" />
|
||||||
|
|
||||||
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
|
<!-- Re-enter the normal app lifecycle after replacement. This preserves the
|
||||||
the TV is never left displaying the old, black Dream surface. -->
|
stored profile/session and performs the next update check before login. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".update.UpdateRecoveryReceiver"
|
android:name=".update.UpdateRecoveryReceiver"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
|
|||||||
@@ -5,15 +5,31 @@ import coil.Coil
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
|
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MembyApp : Application() {
|
class MembyApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Coil.setImageLoader(
|
Coil.setImageLoader(
|
||||||
ImageLoader.Builder(this)
|
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 {
|
||||||
MemoryCache.Builder(this)
|
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()
|
.build()
|
||||||
}
|
}
|
||||||
.diskCache {
|
.diskCache {
|
||||||
@@ -31,4 +47,14 @@ class MembyApp : Application() {
|
|||||||
)
|
)
|
||||||
ServiceLocator.init(this)
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ package com.ponzischeme89.memby.data
|
|||||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayAuthError
|
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
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.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
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.EmbyApi
|
||||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||||
|
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -80,6 +83,11 @@ data class Playable(
|
|||||||
val mediaSourceId: String = "",
|
val mediaSourceId: String = "",
|
||||||
val playSessionId: String = "",
|
val playSessionId: String = "",
|
||||||
val playMethod: String = "DirectPlay",
|
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) {
|
class EmbyRepository(private val settings: SettingsStore) {
|
||||||
@@ -96,11 +104,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
|
val settingsFlow: Flow<Settings> 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<String>(extraBufferCapacity = 1)
|
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val playbackStops = _playbackStops.asSharedFlow()
|
val playbackStops = _playbackStops.asSharedFlow()
|
||||||
private val playableMutex = Mutex()
|
private val playableMutex = Mutex()
|
||||||
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
||||||
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
||||||
|
private val seriesEpisodesMutex = Mutex()
|
||||||
|
private val seriesEpisodesCache =
|
||||||
|
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||||
|
private val relatedMutex = Mutex()
|
||||||
|
private val relatedCache =
|
||||||
|
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
||||||
|
|
||||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||||
|
|
||||||
@@ -168,14 +189,14 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
deviceName: String,
|
deviceName: String,
|
||||||
): String {
|
): String {
|
||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
|
clearSeriesEpisodeCache()
|
||||||
settings.ensureDeviceId()
|
settings.ensureDeviceId()
|
||||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||||
|
|
||||||
ServerConfig.gatewayUrl?.let { gateway ->
|
ServerConfig.gatewayUrl?.let { gateway ->
|
||||||
// The gateway holds the Emby token; this device only ever stores the 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.
|
// token, so the same `token` slot in DataStore serves both modes.
|
||||||
val result = try {
|
val result = requireGateway().login(
|
||||||
requireGateway().login(
|
|
||||||
GatewayLoginRequest(
|
GatewayLoginRequest(
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
@@ -183,12 +204,6 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
deviceName = deviceName.trim(),
|
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()) {
|
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
|
||||||
"Gateway did not return a session"
|
"Gateway did not return a session"
|
||||||
}
|
}
|
||||||
@@ -220,13 +235,39 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
return username
|
return username
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authPolicy(): GatewayAuthPolicy? =
|
suspend fun devices(): List<GatewayDevice> =
|
||||||
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
|
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 {
|
suspend fun validateSession(): Boolean {
|
||||||
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
|
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
|
cachedApi = null
|
||||||
cachedBaseUrl = null
|
cachedBaseUrl = null
|
||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
|
clearSeriesEpisodeCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signOut() {
|
suspend fun signOut() {
|
||||||
@@ -252,6 +294,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
cachedApi = null
|
cachedApi = null
|
||||||
cachedBaseUrl = null
|
cachedBaseUrl = null
|
||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
|
clearSeriesEpisodeCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun switchProfile(profile: EmbyProfile) {
|
suspend fun switchProfile(profile: EmbyProfile) {
|
||||||
@@ -260,6 +303,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
cachedApi = null
|
cachedApi = null
|
||||||
cachedBaseUrl = null
|
cachedBaseUrl = null
|
||||||
clearPlayableCache()
|
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 -------------------------------------------------------------
|
// --- Content -------------------------------------------------------------
|
||||||
@@ -345,7 +406,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
"SortOrder" to "Ascending",
|
"SortOrder" to "Ascending",
|
||||||
"Limit" to limit.toString(),
|
"Limit" to limit.toString(),
|
||||||
),
|
),
|
||||||
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
|
fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio",
|
||||||
imageTypes = "Backdrop,Primary,Logo",
|
imageTypes = "Backdrop,Primary,Logo",
|
||||||
includeUserData = true,
|
includeUserData = true,
|
||||||
)
|
)
|
||||||
@@ -443,6 +504,23 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
.getOrDefault(emptyList())
|
.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
|
||||||
|
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
|
* 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
|
* 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
|
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun getRecommendationOnboarding():
|
||||||
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
|
com.ponzischeme89.memby.data.model.RecommendationOnboarding {
|
||||||
* position to decide, and null on failure — an unreachable gateway must never leave
|
if (!ServerConfig.isGateway) {
|
||||||
* a TV stuck behind a blocking update prompt.
|
return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true)
|
||||||
*/
|
}
|
||||||
suspend fun checkAppUpdate(): GatewayUpdate? {
|
return requireGateway().recommendationPreferences()
|
||||||
if (!ServerConfig.isGateway) return null
|
}
|
||||||
return runCatching { requireGateway().updateStatus() }
|
|
||||||
.getOrNull()
|
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
|
||||||
?.takeIf { it.isActionable }
|
if (!ServerConfig.isGateway) return
|
||||||
|
requireGateway().saveRecommendationPreferences(
|
||||||
|
com.ponzischeme89.memby.data.model.RecommendationPreferences(
|
||||||
|
ratings = ratings.filterValues { it in 1..5 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
|
||||||
|
if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList()
|
||||||
|
|
||||||
|
suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
|
||||||
|
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. */
|
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
|
||||||
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
|
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
|
||||||
|
|
||||||
|
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
|
||||||
|
|
||||||
/** Full item metadata, requested only after focus settles on an item. */
|
/** Full item metadata, requested only after focus settles on an item. */
|
||||||
suspend fun getItemDetails(itemId: String): BaseItem {
|
suspend fun getItemDetails(itemId: String): BaseItem {
|
||||||
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
||||||
@@ -478,21 +607,117 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
return requireApi().getItem(
|
return requireApi().getItem(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
itemId = itemId,
|
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
|
* 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.
|
* list filter, keeping the detail screen immediate after its first load.
|
||||||
*/
|
*/
|
||||||
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
|
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
|
||||||
if (seriesId.isBlank()) return emptyList()
|
if (seriesId.isBlank()) return emptyList()
|
||||||
if (ServerConfig.isGateway) {
|
val now = System.currentTimeMillis()
|
||||||
return requireGateway().seriesEpisodes(seriesId).items
|
seriesEpisodesMutex.withLock {
|
||||||
|
seriesEpisodesCache[seriesId]
|
||||||
|
?.takeIf { it.expiresAtMs > now }
|
||||||
|
?.episodes
|
||||||
|
?.let { return it }
|
||||||
|
seriesEpisodesCache.remove(seriesId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val loaded = if (ServerConfig.isGateway) {
|
||||||
|
requireGateway().seriesEpisodes(seriesId).items
|
||||||
|
} else {
|
||||||
val userId = snapshot.userId ?: error("Not connected")
|
val userId = snapshot.userId ?: error("Not connected")
|
||||||
return requireApi().getEpisodes(
|
requireApi().getEpisodes(
|
||||||
seriesId,
|
seriesId,
|
||||||
mapOf(
|
mapOf(
|
||||||
"UserId" to userId,
|
"UserId" to userId,
|
||||||
@@ -505,6 +730,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
),
|
),
|
||||||
).items
|
).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. */
|
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
|
||||||
private suspend fun getHomeItems(
|
private suspend fun getHomeItems(
|
||||||
@@ -597,6 +836,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
.getOrDefault(GatewayPrerollSchedule())
|
.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. */
|
/** Backdrop rotation interval, clamped to a sane range. */
|
||||||
fun rotationIntervalMillis(): Long =
|
fun rotationIntervalMillis(): Long =
|
||||||
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
||||||
@@ -641,14 +890,15 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
itemId: String,
|
itemId: String,
|
||||||
title: String,
|
title: String,
|
||||||
resumePositionMs: Long,
|
resumePositionMs: Long,
|
||||||
|
forceTranscode: Boolean = false,
|
||||||
): Playable {
|
): Playable {
|
||||||
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
|
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
|
||||||
if (!ServerConfig.isGateway) {
|
if (!ServerConfig.isGateway) {
|
||||||
val discovery = directPlayback(itemId, resumePositionMs)
|
val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode)
|
||||||
return Playable(
|
return Playable(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
title = title,
|
title = title,
|
||||||
url = buildStreamUrl(itemId),
|
url = discovery.url ?: buildStreamUrl(itemId),
|
||||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||||
subtitles = discovery.subtitles,
|
subtitles = discovery.subtitles,
|
||||||
mediaSourceId = discovery.mediaSourceId,
|
mediaSourceId = discovery.mediaSourceId,
|
||||||
@@ -662,6 +912,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
itemType = "",
|
itemType = "",
|
||||||
title = title,
|
title = title,
|
||||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||||
|
forceTranscode = forceTranscode,
|
||||||
)
|
)
|
||||||
return Playable(
|
return Playable(
|
||||||
itemId = playback.itemId,
|
itemId = playback.itemId,
|
||||||
@@ -700,6 +951,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
mediaSourceId = playback.mediaSourceId,
|
mediaSourceId = playback.mediaSourceId,
|
||||||
playSessionId = playback.playSessionId,
|
playSessionId = playback.playSessionId,
|
||||||
playMethod = playback.playMethod,
|
playMethod = playback.playMethod,
|
||||||
|
overview = playback.overview.ifBlank { null },
|
||||||
|
episodeCode = playback.episodeCode.ifBlank { null },
|
||||||
|
runtimeMs = playback.runtimeMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val discovery = directPlayback(
|
val discovery = directPlayback(
|
||||||
@@ -765,6 +1019,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
mediaSourceId = playback.mediaSourceId,
|
mediaSourceId = playback.mediaSourceId,
|
||||||
playSessionId = playback.playSessionId,
|
playSessionId = playback.playSessionId,
|
||||||
playMethod = playback.playMethod,
|
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) {
|
if (item.isSeries) {
|
||||||
@@ -777,28 +1037,34 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
}
|
}
|
||||||
val discovery = directPlayback(episode.id, episode.resumePositionMs)
|
val discovery = directPlayback(episode.id, episode.resumePositionMs)
|
||||||
return Playable(
|
return Playable(
|
||||||
episode.id,
|
itemId = episode.id,
|
||||||
title,
|
title = title,
|
||||||
buildStreamUrl(episode.id),
|
url = discovery.url ?: buildStreamUrl(episode.id),
|
||||||
episode.resumePositionMs,
|
resumePositionMs = episode.resumePositionMs,
|
||||||
logoUrl(item),
|
logoUrl = logoUrl(item),
|
||||||
discovery.subtitles,
|
subtitles = discovery.subtitles,
|
||||||
discovery.mediaSourceId,
|
mediaSourceId = discovery.mediaSourceId,
|
||||||
discovery.playSessionId,
|
playSessionId = discovery.playSessionId,
|
||||||
discovery.playMethod,
|
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)
|
val discovery = directPlayback(item.id, item.resumePositionMs)
|
||||||
return Playable(
|
return Playable(
|
||||||
item.id,
|
itemId = item.id,
|
||||||
item.name,
|
title = item.name,
|
||||||
buildStreamUrl(item.id),
|
url = discovery.url ?: buildStreamUrl(item.id),
|
||||||
item.resumePositionMs,
|
resumePositionMs = item.resumePositionMs,
|
||||||
logoUrl(item),
|
logoUrl = logoUrl(item),
|
||||||
discovery.subtitles,
|
subtitles = discovery.subtitles,
|
||||||
discovery.mediaSourceId,
|
mediaSourceId = discovery.mediaSourceId,
|
||||||
discovery.playSessionId,
|
playSessionId = discovery.playSessionId,
|
||||||
discovery.playMethod,
|
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) {
|
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
||||||
@@ -823,12 +1099,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
isPaused: Boolean,
|
isPaused: Boolean,
|
||||||
eventName: String,
|
eventName: String,
|
||||||
) {
|
durationMs: Long = 0L,
|
||||||
|
): String? {
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
|
return requireGateway().report(
|
||||||
return
|
"progress",
|
||||||
|
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
|
||||||
|
).autoFollowedShowTitle.takeIf(String::isNotBlank)
|
||||||
}
|
}
|
||||||
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
|
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||||
@@ -842,6 +1122,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearPlayableCache()
|
clearPlayableCache()
|
||||||
|
// Episode progress and watched badges may have changed during playback.
|
||||||
|
clearSeriesEpisodeCache()
|
||||||
_playbackStops.tryEmit(session.itemId)
|
_playbackStops.tryEmit(session.itemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -894,7 +1176,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
val next = episodes.getOrNull(current + 1) ?: return null
|
val next = episodes.getOrNull(current + 1) ?: return null
|
||||||
val discovery = directPlayback(next.id, next.resumePositionMs)
|
val discovery = directPlayback(next.id, next.resumePositionMs)
|
||||||
return nextEpisodeOf(
|
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,
|
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -926,6 +1208,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
subtitleStreamIndex: Int? = null,
|
subtitleStreamIndex: Int? = null,
|
||||||
currentPlaySessionId: String? = null,
|
currentPlaySessionId: String? = null,
|
||||||
|
forceTranscode: Boolean = false,
|
||||||
): PlaybackDiscovery {
|
): PlaybackDiscovery {
|
||||||
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||||
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||||
@@ -938,11 +1221,27 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
id = itemId,
|
id = itemId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
startTimeTicks = millisecondsToTicks(positionMs),
|
startTimeTicks = millisecondsToTicks(positionMs),
|
||||||
|
enableDirectPlay = !forceTranscode,
|
||||||
|
enableDirectStream = !forceTranscode,
|
||||||
subtitleStreamIndex = subtitleStreamIndex,
|
subtitleStreamIndex = subtitleStreamIndex,
|
||||||
currentPlaySessionId = currentPlaySessionId,
|
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 ->
|
info.mediaSources.firstOrNull()?.let { source ->
|
||||||
|
val delivery = selectPlaybackDelivery(
|
||||||
|
source = source,
|
||||||
|
forceTranscode = forceTranscode || subtitleStreamIndex != null,
|
||||||
|
)
|
||||||
PlaybackDiscovery(
|
PlaybackDiscovery(
|
||||||
subtitles = subtitleTracks(
|
subtitles = subtitleTracks(
|
||||||
streams = source.mediaStreams,
|
streams = source.mediaStreams,
|
||||||
@@ -953,12 +1252,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
),
|
),
|
||||||
mediaSourceId = source.id.ifBlank { itemId },
|
mediaSourceId = source.id.ifBlank { itemId },
|
||||||
playSessionId = info.playSessionId,
|
playSessionId = info.playSessionId,
|
||||||
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
|
playMethod = delivery.playMethod,
|
||||||
"Transcode"
|
url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
||||||
} else "DirectPlay",
|
|
||||||
url = source.transcodingUrl
|
|
||||||
?.takeIf { subtitleStreamIndex != null }
|
|
||||||
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
|
||||||
)
|
)
|
||||||
} ?: PlaybackDiscovery(mediaSourceId = itemId)
|
} ?: PlaybackDiscovery(mediaSourceId = itemId)
|
||||||
}.getOrElse { 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
|
* 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.
|
* gateway's image proxy like any other.
|
||||||
*/
|
*/
|
||||||
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
|
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
|
||||||
@@ -1060,7 +1355,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
append(gateway.trimEnd('/'))
|
append(gateway.trimEnd('/'))
|
||||||
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
|
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
|
||||||
append("?maxWidth=").append(maxWidth)
|
append("?maxWidth=").append(maxWidth)
|
||||||
append("&quality=90")
|
append("&quality=").append(ARTWORK_QUALITY)
|
||||||
append("&tag=").append(encode(tag))
|
append("&tag=").append(encode(tag))
|
||||||
append("&t=").append(encode(token))
|
append("&t=").append(encode(token))
|
||||||
}
|
}
|
||||||
@@ -1072,7 +1367,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
append("/Items/").append(itemId).append("/Images/").append(imageType)
|
append("/Items/").append(itemId).append("/Images/").append(imageType)
|
||||||
if (directIndex) append("/0")
|
if (directIndex) append("/0")
|
||||||
append("?maxWidth=").append(maxWidth)
|
append("?maxWidth=").append(maxWidth)
|
||||||
append("&quality=90")
|
append("&quality=").append(ARTWORK_QUALITY)
|
||||||
append("&tag=").append(encode(tag))
|
append("&tag=").append(encode(tag))
|
||||||
token?.let { append("&api_key=").append(encode(it)) }
|
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 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(
|
data class PlaybackSession(
|
||||||
@@ -1126,10 +1437,52 @@ private data class PlaybackDiscovery(
|
|||||||
val url: String? = null,
|
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(
|
GatewayPlaybackReport(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
|
durationMs = durationMs.coerceAtLeast(0L),
|
||||||
isPaused = isPaused,
|
isPaused = isPaused,
|
||||||
mediaSourceId = mediaSourceId,
|
mediaSourceId = mediaSourceId,
|
||||||
playSessionId = playSessionId,
|
playSessionId = playSessionId,
|
||||||
@@ -1137,13 +1490,51 @@ private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, e
|
|||||||
eventName = eventName,
|
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(
|
private data class CachedPlayable(
|
||||||
val playable: Playable,
|
val playable: Playable,
|
||||||
val expiresAtMs: Long,
|
val expiresAtMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class CachedSeriesEpisodes(
|
||||||
|
val episodes: List<BaseItem>,
|
||||||
|
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<String> = emptyList(),
|
||||||
|
val items: List<BaseItem> = 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_SIZE = 16
|
||||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
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 =
|
internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||||
milliseconds.coerceAtLeast(0L) * 10_000L
|
milliseconds.coerceAtLeast(0L) * 10_000L
|
||||||
@@ -1151,29 +1542,9 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
|
|||||||
private val BaseItem.resumePositionMs: Long
|
private val BaseItem.resumePositionMs: Long
|
||||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
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. */
|
/** Shared across the error-body parsers below; building a Json format per call is costly. */
|
||||||
private val errorBodyJson = Json { ignoreUnknownKeys = true }
|
private val errorBodyJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
|
|
||||||
if (body.isBlank()) return null
|
|
||||||
return runCatching {
|
|
||||||
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(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
|
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
|
||||||
* bodies or stack traces (which could contain tokens) to the screen.
|
* 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. */
|
/** True when the gateway has rejected the persisted session token. */
|
||||||
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
|
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
|
@Serializable
|
||||||
private data class MaintenanceResponse(
|
private data class MaintenanceResponse(
|
||||||
val maintenance: Boolean = false,
|
val maintenance: Boolean = false,
|
||||||
|
|||||||
@@ -20,14 +20,19 @@ data class MaintenanceNotice(val message: String)
|
|||||||
data class CompatibilityNotice(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
|
* One informational banner: a show aired, a film was added, the library finished
|
||||||
* never actionable and never focusable — it slides in, says its piece and goes.
|
* 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(
|
data class ServiceAlert(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val message: String,
|
val message: String,
|
||||||
val posterUrl: String?,
|
val label: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,7 +182,10 @@ class MaintenanceMonitor(
|
|||||||
id = next.id,
|
id = next.id,
|
||||||
title = next.title.trim(),
|
title = next.title.trim(),
|
||||||
message = next.message.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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ package com.ponzischeme89.memby.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -25,7 +29,20 @@ import kotlinx.serialization.encodeToString
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> 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<Preferences> by preferencesDataStore(
|
||||||
|
name = "emby_settings",
|
||||||
|
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||||
|
)
|
||||||
|
|
||||||
/** Persisted connection state. */
|
/** Persisted connection state. */
|
||||||
data class Settings(
|
data class Settings(
|
||||||
@@ -46,6 +63,8 @@ data class Settings(
|
|||||||
val showTitleLogo: Boolean = true,
|
val showTitleLogo: Boolean = true,
|
||||||
// Slide up a "next up" banner near the end of an episode and roll into the next one.
|
// Slide up a "next up" banner near the end of an episode and roll into the next one.
|
||||||
val autoPlayNextEpisode: Boolean = true,
|
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.
|
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
|
||||||
val ringColorHex: String = DEFAULT_RING_COLOR,
|
val ringColorHex: String = DEFAULT_RING_COLOR,
|
||||||
val lastBackdropUrl: String? = null,
|
val lastBackdropUrl: String? = null,
|
||||||
@@ -57,7 +76,25 @@ data class Settings(
|
|||||||
/** Whether this profile has discovered the dedicated For You destination. */
|
/** Whether this profile has discovered the dedicated For You destination. */
|
||||||
val hasOpenedForYou: Boolean = false,
|
val hasOpenedForYou: Boolean = false,
|
||||||
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
|
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,
|
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<String> = emptySet(),
|
||||||
val profiles: List<EmbyProfile> = emptyList(),
|
val profiles: List<EmbyProfile> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isSignedIn: Boolean
|
val isSignedIn: Boolean
|
||||||
@@ -68,11 +105,17 @@ data class Settings(
|
|||||||
it.userId == userId && it.serverUrl == serverUrl
|
it.userId == userId && it.serverUrl == serverUrl
|
||||||
}?.id
|
}?.id
|
||||||
|
|
||||||
|
/** True once this TV has seen the active profile finish onboarding. */
|
||||||
|
val hasCompletedOnboarding: Boolean
|
||||||
|
get() = userId?.let { it in onboardedUserIds } == true
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_ROTATION_SECONDS = 15
|
const val DEFAULT_ROTATION_SECONDS = 15
|
||||||
const val DEFAULT_RING_COLOR = "FFFFFF"
|
const val DEFAULT_RING_COLOR = "FFFFFF"
|
||||||
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
|
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
|
||||||
const val DEFAULT_HOME_CARD_DENSITY = "standard"
|
const val DEFAULT_HOME_CARD_DENSITY = "standard"
|
||||||
|
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
|
||||||
|
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
|
||||||
val EMPTY = Settings()
|
val EMPTY = Settings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +131,15 @@ data class EmbyProfile(
|
|||||||
val homeCacheJson: String? = null,
|
val homeCacheJson: String? = null,
|
||||||
val forYouMinutes: Int = 0,
|
val forYouMinutes: Int = 0,
|
||||||
val hasOpenedForYou: Boolean = false,
|
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) {
|
class SettingsStore(private val context: Context) {
|
||||||
@@ -95,6 +147,14 @@ class SettingsStore(private val context: Context) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var latestSettings: Settings? = null
|
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<String, HomeCache?>? = null
|
||||||
|
|
||||||
val current: Settings?
|
val current: Settings?
|
||||||
get() = latestSettings
|
get() = latestSettings
|
||||||
|
|
||||||
@@ -113,6 +173,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
||||||
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
||||||
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
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 RING_COLOR = stringPreferencesKey("ring_color")
|
||||||
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
||||||
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
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 FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
|
||||||
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
|
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
|
||||||
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
|
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 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 PROFILES = stringPreferencesKey("profiles")
|
||||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
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<Settings> = context.dataStore.data
|
val settingsFlow: Flow<Settings> = context.dataStore.data
|
||||||
.map(::settingsFrom)
|
.map(::settingsFrom)
|
||||||
.distinctUntilChanged()
|
.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)
|
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
|
||||||
|
|
||||||
suspend fun setRotationIntervalSeconds(seconds: Int) {
|
suspend fun setRotationIntervalSeconds(seconds: Int) {
|
||||||
@@ -189,6 +274,10 @@ class SettingsStore(private val context: Context) {
|
|||||||
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
|
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) {
|
suspend fun setRingColor(hex: String) {
|
||||||
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
|
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
|
||||||
}
|
}
|
||||||
@@ -199,41 +288,140 @@ class SettingsStore(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun setHomeSections(sections: List<String>) {
|
suspend fun setHomeSections(sections: List<String>) {
|
||||||
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
|
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
|
||||||
context.dataStore.edit {
|
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
|
||||||
it[Keys.HOME_SECTIONS] = 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) {
|
suspend fun setHomeCardDensity(density: String) {
|
||||||
context.dataStore.edit {
|
val selected = density.takeIf { it in setOf("compact", "standard", "large") }
|
||||||
it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") }
|
|
||||||
?: Settings.DEFAULT_HOME_CARD_DENSITY
|
?: 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) {
|
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 ->
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
|
||||||
|
updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setHomeRowPreferences(
|
||||||
|
order: List<String>,
|
||||||
|
pinned: Set<String>,
|
||||||
|
hidden: Set<String>,
|
||||||
|
) {
|
||||||
|
fun encode(values: Iterable<String>): 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)
|
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
|
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 {
|
} else {
|
||||||
profile
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (profiles.isNotEmpty()) {
|
lastPersistedHomeCache = encodedCache
|
||||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<String>? {
|
||||||
|
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) {
|
suspend fun setForYouMinutes(minutes: Int) {
|
||||||
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
|
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
|
||||||
context.dataStore.edit { preferences ->
|
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() {
|
suspend fun markForYouOpened() {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
preferences[Keys.HAS_OPENED_FOR_YOU] = true
|
preferences[Keys.HAS_OPENED_FOR_YOU] = true
|
||||||
@@ -263,13 +466,55 @@ class SettingsStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (profiles.isNotEmpty()) {
|
if (profiles.isNotEmpty()) {
|
||||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
writeProfiles(preferences, profiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
|
/**
|
||||||
runCatching { Json.decodeFromString<HomeCache>(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<EmbyProfile>) {
|
||||||
|
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<HomeCache>(json) }.getOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads DataStore directly instead of taking the replayed flow value. This matters
|
* Reads DataStore directly instead of taking the replayed flow value. This matters
|
||||||
@@ -302,10 +547,21 @@ class SettingsStore(private val context: Context) {
|
|||||||
homeCacheJson = previous?.homeCacheJson,
|
homeCacheJson = previous?.homeCacheJson,
|
||||||
forYouMinutes = previous?.forYouMinutes ?: 0,
|
forYouMinutes = previous?.forYouMinutes ?: 0,
|
||||||
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
|
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.removeAll { it.id == id }
|
||||||
profiles.add(profile)
|
profiles.add(profile)
|
||||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
writeProfiles(preferences, profiles)
|
||||||
applyProfile(preferences, profile)
|
applyProfile(preferences, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,12 +571,30 @@ class SettingsStore(private val context: Context) {
|
|||||||
val profiles = profilesFrom(preferences).toMutableList()
|
val profiles = profilesFrom(preferences).toMutableList()
|
||||||
if (profiles.none { it.id == profile.id }) {
|
if (profiles.none { it.id == profile.id }) {
|
||||||
profiles.add(profile)
|
profiles.add(profile)
|
||||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
writeProfiles(preferences, profiles)
|
||||||
}
|
}
|
||||||
applyProfile(preferences, profile)
|
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() {
|
suspend fun clearSession() {
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
clearActiveSession(it)
|
clearActiveSession(it)
|
||||||
@@ -340,7 +614,8 @@ class SettingsStore(private val context: Context) {
|
|||||||
val remaining = profilesFrom(preferences).filterNot {
|
val remaining = profilesFrom(preferences).filterNot {
|
||||||
it.userId == activeUserId && it.serverUrl == activeServer
|
it.userId == activeUserId && it.serverUrl == activeServer
|
||||||
}
|
}
|
||||||
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
|
writeProfiles(preferences, remaining)
|
||||||
|
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
|
||||||
clearActiveSession(preferences)
|
clearActiveSession(preferences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,8 +627,18 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences.remove(Keys.SERVER_ID)
|
preferences.remove(Keys.SERVER_ID)
|
||||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||||
preferences.remove(Keys.HOME_CACHE)
|
preferences.remove(Keys.HOME_CACHE)
|
||||||
|
lastPersistedHomeCache = null
|
||||||
preferences.remove(Keys.FOR_YOU_MINUTES)
|
preferences.remove(Keys.FOR_YOU_MINUTES)
|
||||||
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
|
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)
|
preferences.remove(Keys.USERNAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +649,36 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences[Keys.USERNAME] = profile.username
|
preferences[Keys.USERNAME] = profile.username
|
||||||
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
|
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
|
||||||
else preferences[Keys.SERVER_ID] = profile.serverId
|
else preferences[Keys.SERVER_ID] = profile.serverId
|
||||||
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
|
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
|
||||||
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
|
// 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.FOR_YOU_MINUTES] = profile.forYouMinutes
|
||||||
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
|
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)
|
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,9 +694,21 @@ class SettingsStore(private val context: Context) {
|
|||||||
userId = userId,
|
userId = userId,
|
||||||
username = username,
|
username = username,
|
||||||
serverId = preferences[Keys.SERVER_ID],
|
serverId = preferences[Keys.SERVER_ID],
|
||||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
homeCacheJson = activeHomeCache(preferences),
|
||||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
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],
|
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||||
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: 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,
|
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
homeCacheJson = activeHomeCache(preferences),
|
||||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
||||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
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,
|
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,
|
profiles = profiles,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,17 +27,32 @@ class RowAnalytics(
|
|||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
private val buffer = ArrayList<GatewayRowEvent>()
|
private val buffer = ArrayList<GatewayRowEvent>()
|
||||||
private val impressed = HashSet<String>()
|
private val impressed = HashSet<String>()
|
||||||
|
private val impressedItems = HashSet<String>()
|
||||||
|
|
||||||
private var focusedRowId: String? = null
|
private var focusedRowId: String? = null
|
||||||
private var focusedRowKind: String = ""
|
private var focusedRowKind: String = ""
|
||||||
private var focusStartedAt: Long = 0
|
private var focusStartedAt: Long = 0
|
||||||
|
|
||||||
/** Records that a row was drawn. Repeats are ignored until [reset]. */
|
/** 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<String> = emptyList()) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (!impressed.add(rowId)) return
|
if (impressed.add(rowId)) {
|
||||||
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
|
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) {
|
synchronized(lock) {
|
||||||
buffer.clear()
|
buffer.clear()
|
||||||
impressed.clear()
|
impressed.clear()
|
||||||
|
impressedItems.clear()
|
||||||
focusedRowId = null
|
focusedRowId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.ponzischeme89.memby.data.model
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ data class UserItemData(
|
|||||||
@SerialName("IsFavorite") val isFavorite: Boolean = false,
|
@SerialName("IsFavorite") val isFavorite: Boolean = false,
|
||||||
@SerialName("Played") val played: Boolean = false,
|
@SerialName("Played") val played: Boolean = false,
|
||||||
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
|
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
|
||||||
|
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -53,6 +57,11 @@ data class PlaybackInfoRequest(
|
|||||||
@SerialName("Id") val id: String,
|
@SerialName("Id") val id: String,
|
||||||
@SerialName("UserId") val userId: String,
|
@SerialName("UserId") val userId: String,
|
||||||
@SerialName("IsPlayback") val isPlayback: Boolean = true,
|
@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("StartTimeTicks") val startTimeTicks: Long = 0,
|
||||||
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
|
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
|
||||||
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
|
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
|
||||||
@@ -66,9 +75,12 @@ data class DeviceProfile(
|
|||||||
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
|
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
|
||||||
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
|
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
|
||||||
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
|
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
|
||||||
|
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun embyAndroidTv() = DeviceProfile(
|
fun embyAndroidTv(
|
||||||
|
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
|
||||||
|
) = DeviceProfile(
|
||||||
name = "Memby Android TV",
|
name = "Memby Android TV",
|
||||||
subtitleProfiles = listOf(
|
subtitleProfiles = listOf(
|
||||||
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
||||||
@@ -77,23 +89,143 @@ data class DeviceProfile(
|
|||||||
).map { SubtitleProfile(it, "Encode") },
|
).map { SubtitleProfile(it, "Encode") },
|
||||||
directPlayProfiles = listOf(
|
directPlayProfiles = listOf(
|
||||||
DirectPlayProfile(
|
DirectPlayProfile(
|
||||||
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
|
// The broad codec declaration is bounded by CodecProfiles below.
|
||||||
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
|
container = "mkv,mp4,m4v,mov,ts,mpegts",
|
||||||
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
|
videoCodec = directPlayVideoCodecs(capabilities),
|
||||||
|
audioCodec = "aac,mp3",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
transcodingProfiles = listOf(
|
transcodingProfiles = listOf(
|
||||||
TranscodingProfile(
|
TranscodingProfile(
|
||||||
container = "ts",
|
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",
|
audioCodec = "aac",
|
||||||
protocol = "hls",
|
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<CodecProfile> =
|
||||||
|
buildList {
|
||||||
|
addVideoProfiles("h264", capabilities.h264)
|
||||||
|
addVideoProfiles("hevc", capabilities.hevc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<CodecProfile>.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<ProfileCondition>,
|
||||||
|
@SerialName("ApplyConditions") val applyConditions: List<ProfileCondition> = 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
|
@Serializable
|
||||||
data class DirectPlayProfile(
|
data class DirectPlayProfile(
|
||||||
@SerialName("Container") val container: String,
|
@SerialName("Container") val container: String,
|
||||||
@@ -156,6 +288,9 @@ data class PlaybackInfo(
|
|||||||
data class MediaSourceInfo(
|
data class MediaSourceInfo(
|
||||||
@SerialName("Id") val id: String = "",
|
@SerialName("Id") val id: String = "",
|
||||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = 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("DirectStreamUrl") val directStreamUrl: String? = null,
|
||||||
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
|
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
|
||||||
)
|
)
|
||||||
@@ -183,7 +318,9 @@ data class BaseItem(
|
|||||||
@SerialName("CommunityRating") val communityRating: Double? = null,
|
@SerialName("CommunityRating") val communityRating: Double? = null,
|
||||||
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
||||||
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
||||||
|
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
|
||||||
@SerialName("Genres") val genres: List<String> = emptyList(),
|
@SerialName("Genres") val genres: List<String> = emptyList(),
|
||||||
|
@SerialName("CollectionName") val collectionName: String? = null,
|
||||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||||
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
|
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
|
||||||
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
||||||
@@ -205,21 +342,32 @@ data class BaseItem(
|
|||||||
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
|
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
|
||||||
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
|
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
|
||||||
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
|
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
|
||||||
|
@SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null,
|
||||||
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
||||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
@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,
|
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
|
||||||
// Explainability supplied only by the gateway's dedicated For You endpoint.
|
// Explainability supplied only by the gateway's dedicated For You endpoint.
|
||||||
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
|
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
|
||||||
@SerialName("MembyCompatibility") val membyCompatibility: 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<String, Double> = emptyMap(),
|
||||||
|
@SerialName("MembyRecommendationReasonCodes")
|
||||||
|
val membyRecommendationReasonCodes: List<String> = emptyList(),
|
||||||
|
@SerialName("MembyExploration") val membyExploration: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
||||||
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
||||||
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
||||||
val isFavorite: Boolean get() = userData?.isFavorite == 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<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||||
|
|
||||||
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
|
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
|
||||||
|
|||||||
@@ -24,23 +24,25 @@ data class GatewayLoginResponse(
|
|||||||
val userId: String = "",
|
val userId: String = "",
|
||||||
val username: String = "",
|
val username: String = "",
|
||||||
val serverId: String = "",
|
val serverId: String = "",
|
||||||
val activeClients: Int = 0,
|
|
||||||
val maxClientsPerUser: Int = 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayAuthPolicy(
|
data class GatewayDevice(
|
||||||
val maxClientsPerUser: Int = 0,
|
val deviceId: String,
|
||||||
|
val deviceName: String,
|
||||||
|
val clientVersion: String = "",
|
||||||
|
val lastSeenAt: String = "",
|
||||||
|
val current: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayAuthError(
|
data class GatewayDevices(
|
||||||
val error: String = "",
|
val devices: List<GatewayDevice> = emptyList(),
|
||||||
val message: String = "",
|
|
||||||
val activeClients: Int = 0,
|
|
||||||
val maxClientsPerUser: Int = 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One horizontal strip, described entirely by the server.
|
* 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`.
|
* The gateway's verdict on this build, from `GET /v1/update`.
|
||||||
*
|
*
|
||||||
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the
|
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the app before login or
|
||||||
* operator has decided this version may no longer be used.
|
* home is composed — the operator has decided this version may no longer be used.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayUpdate(
|
data class GatewayUpdate(
|
||||||
@@ -85,6 +87,8 @@ data class GatewayUpdate(
|
|||||||
val version: String = "",
|
val version: String = "",
|
||||||
val notes: String = "",
|
val notes: String = "",
|
||||||
val downloadUrl: String = "",
|
val downloadUrl: String = "",
|
||||||
|
val sha256: String = "",
|
||||||
|
val sizeBytes: Long = 0,
|
||||||
) {
|
) {
|
||||||
val isMandatory: Boolean get() = status == STATUS_MANDATORY
|
val isMandatory: Boolean get() = status == STATUS_MANDATORY
|
||||||
val isOptional: Boolean get() = status == STATUS_OPTIONAL
|
val isOptional: Boolean get() = status == STATUS_OPTIONAL
|
||||||
@@ -110,18 +114,47 @@ data class GatewayServiceStatus(
|
|||||||
val clientVersion: String = "",
|
val clientVersion: String = "",
|
||||||
val clientProtocol: String = "",
|
val clientProtocol: String = "",
|
||||||
val serverProtocol: Int = 0,
|
val serverProtocol: Int = 0,
|
||||||
|
val featureSchemaVersion: Int = 0,
|
||||||
|
val featureRevision: Long = 0,
|
||||||
|
val safeMode: Boolean = false,
|
||||||
|
val features: Map<String, Boolean> = 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<GatewayFeature> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An informational nudge riding along on the status poll — currently "this episode aired
|
* An informational nudge riding along on the status poll — "this episode aired and is on
|
||||||
* and is on its way into Emby". It is never actionable: the banner slides in, states the
|
* its way into Emby", or "Radarr just imported this film". It is never actionable: the
|
||||||
* news and leaves. Unknown [kind] values still render, so the server can add one without
|
* banner slides in, states the news and leaves. Unknown [kind] values still render, and
|
||||||
* an app release.
|
* the server supplies the wording, so a new kind of news needs no app release.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayAlert(
|
data class GatewayAlert(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val kind: String = "",
|
val kind: String = "",
|
||||||
|
/** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */
|
||||||
|
val label: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val message: String = "",
|
val message: String = "",
|
||||||
val itemId: String = "",
|
val itemId: String = "",
|
||||||
@@ -135,16 +168,70 @@ data class GatewayRows(
|
|||||||
val rows: List<HomeRow> = emptyList(),
|
val rows: List<HomeRow> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RecommendationOnboarding(
|
||||||
|
val completed: Boolean = false,
|
||||||
|
val ratings: Map<String, Int> = emptyMap(),
|
||||||
|
val items: List<BaseItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RecommendationPreferences(
|
||||||
|
val ratings: Map<String, Int> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayItems(
|
data class GatewayItems(
|
||||||
val items: List<BaseItem> = emptyList(),
|
val items: List<BaseItem> = 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<String> = emptyList(),
|
||||||
|
val items: List<BaseItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewaySearchHistory(
|
data class GatewaySearchHistory(
|
||||||
val queries: List<String> = emptyList(),
|
val queries: List<String> = 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<GatewayRequestCandidate> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayMediaRequest(
|
||||||
|
val mediaType: String,
|
||||||
|
val foreignId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayMediaRequestResult(
|
||||||
|
val status: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayPrerollSchedule(
|
data class GatewayPrerollSchedule(
|
||||||
val today: List<GatewayPrerollEntry> = emptyList(),
|
val today: List<GatewayPrerollEntry> = emptyList(),
|
||||||
@@ -153,6 +240,8 @@ data class GatewayPrerollSchedule(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayPrerollEntry(
|
data class GatewayPrerollEntry(
|
||||||
|
val itemId: String = "",
|
||||||
|
val imageType: String = "",
|
||||||
val series: String = "",
|
val series: String = "",
|
||||||
val episode: String = "",
|
val episode: String = "",
|
||||||
val episodeCode: String = "",
|
val episodeCode: String = "",
|
||||||
@@ -164,6 +253,12 @@ data class GatewayPrerollEntry(
|
|||||||
data class GatewayPlayback(
|
data class GatewayPlayback(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val title: 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 url: String,
|
||||||
val resumePositionMs: Long = 0,
|
val resumePositionMs: Long = 0,
|
||||||
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
|
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
|
||||||
@@ -188,11 +283,72 @@ data class GatewayNextEpisode(
|
|||||||
val playMethod: String = "DirectPlay",
|
val playMethod: String = "DirectPlay",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewaySeasonFinale(
|
||||||
|
val seasonFinale: Boolean = false,
|
||||||
|
val seriesName: String = "",
|
||||||
|
val seasonNumber: Int = 0,
|
||||||
|
val episodeNumber: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayFlagRequest(
|
data class GatewayFlagRequest(
|
||||||
val value: Boolean,
|
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<MyShow> = 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<UserNotification> = emptyList(),
|
||||||
|
val preferences: NotificationPreferences = NotificationPreferences(),
|
||||||
|
)
|
||||||
|
|
||||||
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayRowEvent(
|
data class GatewayRowEvent(
|
||||||
@@ -213,9 +369,15 @@ data class GatewayRowEvents(
|
|||||||
data class GatewayPlaybackReport(
|
data class GatewayPlaybackReport(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
|
val durationMs: Long = 0,
|
||||||
val isPaused: Boolean = false,
|
val isPaused: Boolean = false,
|
||||||
val mediaSourceId: String = "",
|
val mediaSourceId: String = "",
|
||||||
val playSessionId: String = "",
|
val playSessionId: String = "",
|
||||||
val playMethod: String = "DirectPlay",
|
val playMethod: String = "DirectPlay",
|
||||||
val eventName: String? = null,
|
val eventName: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayPlaybackReportResponse(
|
||||||
|
val autoFollowedShowTitle: String = "",
|
||||||
|
)
|
||||||
|
|||||||
+224
@@ -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<String> = 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<String> = 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<String>.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<Int>,
|
||||||
|
levels: List<Pair<Int, Int>>,
|
||||||
|
): Int {
|
||||||
|
val platformLevel = maxPlatformLevel(mime, profiles)
|
||||||
|
return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxPlatformLevel(mime: String, profiles: Set<Int>): 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<Int, Int> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,16 @@ interface EmbyApi {
|
|||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): ItemsResult
|
): 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<String, String>,
|
||||||
|
): ItemsResult
|
||||||
|
|
||||||
@GET("Shows/NextUp")
|
@GET("Shows/NextUp")
|
||||||
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
|
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.ponzischeme89.memby.BuildConfig
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -36,7 +35,9 @@ object EmbyServiceFactory {
|
|||||||
redactHeader("X-Emby-Authorization")
|
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)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package com.ponzischeme89.memby.data.remote
|
|||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
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.GatewayHome
|
||||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
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.GatewayNextEpisode
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
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.GatewayRowEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
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.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
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 com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@@ -35,9 +44,6 @@ interface GatewayApi {
|
|||||||
@POST("v1/auth/login")
|
@POST("v1/auth/login")
|
||||||
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
|
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
|
||||||
|
|
||||||
@GET("v1/auth/policy")
|
|
||||||
suspend fun authPolicy(): GatewayAuthPolicy
|
|
||||||
|
|
||||||
@POST("v1/auth/logout")
|
@POST("v1/auth/logout")
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
|
|
||||||
@@ -45,6 +51,18 @@ interface GatewayApi {
|
|||||||
@GET("v1/auth/session")
|
@GET("v1/auth/session")
|
||||||
suspend fun session(): GatewayLoginResponse
|
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")
|
@GET("v1/home")
|
||||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||||
|
|
||||||
@@ -60,6 +78,12 @@ interface GatewayApi {
|
|||||||
@GET("v1/search/history")
|
@GET("v1/search/history")
|
||||||
suspend fun recentSearches(): GatewaySearchHistory
|
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. */
|
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||||
@GET("v1/recommendations")
|
@GET("v1/recommendations")
|
||||||
suspend fun recommendations(): GatewayRows
|
suspend fun recommendations(): GatewayRows
|
||||||
@@ -67,6 +91,12 @@ interface GatewayApi {
|
|||||||
@GET("v1/for-you")
|
@GET("v1/for-you")
|
||||||
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
|
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")
|
@GET("v1/preroll")
|
||||||
suspend fun prerollSchedule(): GatewayPrerollSchedule
|
suspend fun prerollSchedule(): GatewayPrerollSchedule
|
||||||
|
|
||||||
@@ -77,17 +107,57 @@ interface GatewayApi {
|
|||||||
@GET("v1/update")
|
@GET("v1/update")
|
||||||
suspend fun updateStatus(): GatewayUpdate
|
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. */
|
/** Available during maintenance so an open app can be interrupted immediately. */
|
||||||
@GET("v1/status")
|
@GET("v1/status")
|
||||||
suspend fun serviceStatus(): GatewayServiceStatus
|
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}")
|
@GET("v1/items/{id}")
|
||||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
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. */
|
/** All episodes for a series in display order; the client groups them into seasons. */
|
||||||
@GET("v1/items/{id}/episodes")
|
@GET("v1/items/{id}/episodes")
|
||||||
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
|
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")
|
@GET("v1/items/{id}/playback")
|
||||||
suspend fun playback(
|
suspend fun playback(
|
||||||
@Path("id") itemId: String,
|
@Path("id") itemId: String,
|
||||||
@@ -95,6 +165,7 @@ interface GatewayApi {
|
|||||||
@Query("title") title: String,
|
@Query("title") title: String,
|
||||||
@Query("resumePositionMs") resumePositionMs: Long,
|
@Query("resumePositionMs") resumePositionMs: Long,
|
||||||
@Query("subtitleIndex") subtitleIndex: Int? = null,
|
@Query("subtitleIndex") subtitleIndex: Int? = null,
|
||||||
|
@Query("forceTranscode") forceTranscode: Boolean = false,
|
||||||
): GatewayPlayback
|
): GatewayPlayback
|
||||||
|
|
||||||
/** 404 when nothing follows this item: a movie, or a series finale. */
|
/** 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
|
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
|
||||||
|
|
||||||
@POST("v1/playback/{phase}")
|
@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. */
|
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
|
||||||
@POST("v1/analytics/rows")
|
@POST("v1/analytics/rows")
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.ponzischeme89.memby.data.remote
|
|||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
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 kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -23,7 +24,9 @@ object GatewayServiceFactory {
|
|||||||
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
|
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
|
||||||
val contentType = "application/json".toMediaType()
|
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)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
// The gateway answers home from Redis in single-digit milliseconds; a long
|
// The gateway answers home from Redis in single-digit milliseconds; a long
|
||||||
// read timeout here only ever means Emby itself is struggling behind it.
|
// 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.
|
// says which build it is.
|
||||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||||
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
||||||
|
.header(
|
||||||
|
"X-Memby-Capabilities",
|
||||||
|
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens())
|
||||||
|
.joinToString(","),
|
||||||
|
)
|
||||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||||
builder.header("Authorization", "Bearer $it")
|
builder.header("Authorization", "Bearer $it")
|
||||||
}
|
}
|
||||||
@@ -60,3 +68,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal const val MEMBY_PROTOCOL_VERSION = 1
|
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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
badges: List<String>,
|
||||||
|
tabs: List<DetailTab>,
|
||||||
|
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<String> = emptyList(),
|
||||||
|
heroActions: List<DetailHeroAction> = 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<String>,
|
||||||
|
badges: List<String>,
|
||||||
|
playLabel: String,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
playFocusRequester: FocusRequester,
|
||||||
|
tabFocusRequester: FocusRequester,
|
||||||
|
progress: Float,
|
||||||
|
progressLabel: String?,
|
||||||
|
reasons: List<String>,
|
||||||
|
actions: List<DetailHeroAction>,
|
||||||
|
actionRequesters: List<FocusRequester>,
|
||||||
|
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<String>,
|
||||||
|
badges: List<String> = 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<DetailTab>,
|
||||||
|
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<TechnicalSpec>, 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<TechnicalSpec>,
|
||||||
|
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<TechnicalSpec>,
|
||||||
|
specs: List<TechnicalSpec>,
|
||||||
|
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<BaseItem>,
|
||||||
|
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) }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<HomeBrowseRow>): List<HomeHeroPick> {
|
||||||
|
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<BaseItem>.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<HomeHeroPick>,
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
|||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -18,8 +17,12 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -50,15 +53,29 @@ data class HomeUiState(
|
|||||||
* rows behind it would be stale and unusable anyway.
|
* rows behind it would be stale and unusable anyway.
|
||||||
*/
|
*/
|
||||||
val maintenanceMessage: String? = null,
|
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<BaseItem>
|
val watchingAndNextUp: List<BaseItem>
|
||||||
get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
|
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(
|
fun toCache() = HomeCache(
|
||||||
continueWatching = continueWatching,
|
continueWatching = continueWatching,
|
||||||
nextUp = nextUp,
|
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(
|
data class ForYouUiState(
|
||||||
val rows: List<HomeRow> = emptyList(),
|
val rows: List<HomeRow> = emptyList(),
|
||||||
val availableMinutes: Int = 0,
|
val availableMinutes: Int = 0,
|
||||||
@@ -95,6 +122,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
private val refreshMutex = Mutex()
|
private val refreshMutex = Mutex()
|
||||||
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
||||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
val state: StateFlow<HomeUiState> = _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<HomeUiState> = _state
|
||||||
|
.map(HomeUiState::contentSlice)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())
|
||||||
|
|
||||||
|
/** Connection health. Does not emit when rows change. */
|
||||||
|
val status: StateFlow<HomeStatus> = _state
|
||||||
|
.map(HomeUiState::statusSlice)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())
|
||||||
|
|
||||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||||
private val _forYou = MutableStateFlow(ForYouUiState())
|
private val _forYou = MutableStateFlow(ForYouUiState())
|
||||||
@@ -107,20 +153,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
/** Row engagement, buffered here and uploaded in batches. */
|
/** Row engagement, buffered here and uploaded in batches. */
|
||||||
private val analytics = RowAnalytics()
|
private val analytics = RowAnalytics()
|
||||||
|
|
||||||
/** Optional prompt the viewer waved away; forgotten when the app restarts. */
|
|
||||||
private var dismissedUpdateVersion: String? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshAll()
|
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 {
|
viewModelScope.launch {
|
||||||
repository.playbackStops.collect { refreshWatching() }
|
repository.playbackStops.collect { refreshWatching() }
|
||||||
}
|
}
|
||||||
@@ -134,29 +168,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
|
||||||
* Asks the gateway whether this build is still allowed. Runs on every launch, so an
|
analytics.rowImpression(rowId, rowKind, visibleItemIds)
|
||||||
* 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 trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
||||||
analytics.rowFocused(rowId, rowKind, itemId)
|
analytics.rowFocused(rowId, rowKind, itemId)
|
||||||
@@ -286,6 +299,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
||||||
val focused = (cached ?: item).copy(
|
val focused = (cached ?: item).copy(
|
||||||
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
|
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
|
||||||
|
membyRecommendationReason = item.membyRecommendationReason
|
||||||
|
?: cached?.membyRecommendationReason,
|
||||||
|
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
|
||||||
)
|
)
|
||||||
_focusedItem.value = focused
|
_focusedItem.value = focused
|
||||||
metadataJob?.cancel()
|
metadataJob?.cancel()
|
||||||
@@ -298,7 +314,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
if (item.membyPlayable) {
|
if (item.membyPlayable) {
|
||||||
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
|
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 {
|
launch {
|
||||||
val details = runCatching {
|
val details = runCatching {
|
||||||
repository.getItemDetails(item.id)
|
repository.getItemDetails(item.id)
|
||||||
@@ -464,7 +485,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
||||||
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
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? =
|
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
||||||
state.watchingAndNextUp.firstOrNull()
|
state.watchingAndNextUp.firstOrNull()
|
||||||
@@ -489,6 +509,7 @@ private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
|
|||||||
firstOrNull { it.id == "sonarr-airing-today" }
|
firstOrNull { it.id == "sonarr-airing-today" }
|
||||||
?.items
|
?.items
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
|
||||||
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
|
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
|
||||||
.filterTo(mutableSetOf(), String::isNotEmpty)
|
.filterTo(mutableSetOf(), String::isNotEmpty)
|
||||||
|
|
||||||
@@ -497,7 +518,7 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
|
|||||||
|
|
||||||
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
|
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
|
||||||
map { item ->
|
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)
|
item.copy(membyAiringToday = true)
|
||||||
} else {
|
} else {
|
||||||
item
|
item
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<RelatedContent?>(null) }
|
||||||
|
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(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<String?>(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<String>::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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MyShow>,
|
||||||
|
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<String, Color>? = 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<UserNotification>,
|
||||||
|
preferences: NotificationPreferences,
|
||||||
|
onToggleEnabled: () -> Unit,
|
||||||
|
onToggleShowReturns: () -> Unit,
|
||||||
|
onRead: (UserNotification) -> Unit,
|
||||||
|
onDismissNotification: (UserNotification) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Show-return alerts are saved for this user on every Memby TV.",
|
||||||
|
color = Color(0xFFAEB7BF),
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(onClick = onToggleEnabled) {
|
||||||
|
Icon(
|
||||||
|
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
|
||||||
|
}
|
||||||
|
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
|
||||||
|
Text(
|
||||||
|
if (preferences.showReturnAlerts) {
|
||||||
|
"Return alerts on"
|
||||||
|
} else {
|
||||||
|
"Return alerts off"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notifications.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("You’re all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
items(notifications, key = UserNotification::id) { notification ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
|
||||||
|
RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
.padding(18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(10.dp).background(
|
||||||
|
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
notification.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
if (notification.unread) {
|
||||||
|
Button(onClick = { onRead(notification) }) { Text("Mark read") }
|
||||||
|
}
|
||||||
|
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun formatMyShowDate(value: String?): String {
|
||||||
|
if (value.isNullOrBlank()) return "Not announced"
|
||||||
|
return runCatching {
|
||||||
|
DateTimeFormatter.ofPattern("EEE, d MMM yyyy · h:mm a")
|
||||||
|
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
|
||||||
|
}.getOrDefault("Not announced")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -8,19 +8,34 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Button
|
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
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 com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import androidx.compose.material.icons.Icons
|
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||||
import kotlinx.coroutines.delay
|
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
||||||
|
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||||
private val SeriesGreen = Color(0xFF52B54B)
|
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
||||||
private val SeriesMuted = Color(0xFFD0D6DB)
|
import com.ponzischeme89.memby.ui.detail.detailPositions
|
||||||
private val SeriesQuiet = Color(0xFFAEB7BF)
|
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
|
@Composable
|
||||||
fun SeriesDetailsOverlay(
|
fun SeriesDetailsOverlay(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
onPlay: (BaseItem) -> Unit,
|
onPlay: (BaseItem) -> Unit,
|
||||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||||
|
isMyShow: Boolean,
|
||||||
|
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
|
val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
|
||||||
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
||||||
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
||||||
var selectedSectionKey by rememberSaveable(item.id) {
|
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
||||||
mutableStateOf(SeriesDetailSection.EPISODES.key)
|
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
||||||
}
|
|
||||||
val selectedSection = seriesDetailSection(selectedSectionKey)
|
|
||||||
|
|
||||||
LaunchedEffect(item.id) {
|
LaunchedEffect(item.id) {
|
||||||
runCatching { repository.getSeriesEpisodes(item.id) }
|
runCatching { repository.getSeriesEpisodes(item.id) }
|
||||||
@@ -80,9 +116,50 @@ fun SeriesDetailsOverlay(
|
|||||||
episodes = emptyList()
|
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<BaseItem>?,
|
||||||
|
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()) }
|
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
|
||||||
var selectedSeason by rememberSaveable(item.id) { mutableStateOf<Int?>(null) }
|
var selectedSeason by rememberSaveable(item.id) { mutableStateOf(remembered.season) }
|
||||||
LaunchedEffect(seasons) {
|
LaunchedEffect(seasons) {
|
||||||
if (selectedSeason !in seasons) {
|
if (selectedSeason !in seasons) {
|
||||||
selectedSeason = defaultSeason(episodes.orEmpty())
|
selectedSeason = defaultSeason(episodes.orEmpty())
|
||||||
@@ -91,132 +168,202 @@ fun SeriesDetailsOverlay(
|
|||||||
val seasonEpisodes = remember(episodes, selectedSeason) {
|
val seasonEpisodes = remember(episodes, selectedSeason) {
|
||||||
episodesForSeason(episodes.orEmpty(), selectedSeason)
|
episodesForSeason(episodes.orEmpty(), selectedSeason)
|
||||||
}
|
}
|
||||||
val firstEpisode = remember { FocusRequester() }
|
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
|
||||||
val seasonFocusRequesters = remember(seasons) {
|
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
|
||||||
seasons.associateWith { FocusRequester() }
|
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) {
|
// Fixed on the first frame from what the item *is*. A series has episodes, a cast and
|
||||||
val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect
|
// technical details whether or not they have arrived yet, and a strip that waits for
|
||||||
if (!initialSeasonFocusRequested) {
|
// the network is a strip that moves under the viewer's thumb.
|
||||||
delay(40)
|
val tabs = remember(item.id) { detailTabs(isSeries = true) }
|
||||||
runCatching { requester.requestFocus() }
|
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||||
initialSeasonFocusRequested = true
|
// 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<String?>(null) }
|
||||||
|
LaunchedEffect(confirmation) {
|
||||||
|
if (confirmation != null) {
|
||||||
|
kotlinx.coroutines.delay(1_800L)
|
||||||
|
confirmation = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
DetailPageScaffold(
|
||||||
modifier
|
item = item,
|
||||||
.fillMaxSize()
|
facts = heroFacts(item, seasons.size),
|
||||||
.background(Color(0xFF090B0D)),
|
// 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
|
||||||
BackdropLayer(item, Modifier.fillMaxSize())
|
// badges them.
|
||||||
Box(
|
badges = mediaBadges(item),
|
||||||
Modifier
|
tabs = tabs,
|
||||||
.fillMaxSize()
|
selectedTab = selectedTab,
|
||||||
.background(
|
onSelectTab = { tabKey = it.key },
|
||||||
Brush.verticalGradient(
|
playLabel = primaryActionLabel(item, nextEpisode),
|
||||||
0f to Color.Black.copy(alpha = 0.20f),
|
onPlay = { onPlay(nextEpisode ?: item) },
|
||||||
0.42f to Color.Black.copy(alpha = 0.52f),
|
playFocusRequester = play,
|
||||||
0.72f to Color(0xF5090B0D),
|
tabFocusRequester = tabStrip,
|
||||||
1f to Color(0xFF090B0D),
|
contentFocusRequester = contentEntry,
|
||||||
),
|
modifier = modifier,
|
||||||
),
|
progress = nextEpisode?.let(::playbackProgress) ?: 0f,
|
||||||
)
|
progressLabel = nextEpisode?.let(::remainingLabel),
|
||||||
Box(
|
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
|
||||||
Modifier
|
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
|
||||||
.fillMaxSize()
|
confirmation = confirmation,
|
||||||
.background(
|
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
|
||||||
Brush.horizontalGradient(
|
heroActions = buildList {
|
||||||
0f to Color.Black.copy(alpha = 0.72f),
|
add(
|
||||||
0.60f to Color.Black.copy(alpha = 0.18f),
|
DetailHeroAction(
|
||||||
1f to Color.Transparent,
|
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 = {
|
||||||
Column(
|
val desired = !item.isFavorite
|
||||||
modifier = Modifier
|
onToggleFavorite(item, desired)
|
||||||
.fillMaxSize()
|
confirmation = if (desired) "Added to Favourites" else "Removed from Favourites"
|
||||||
.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()) {
|
if (ServerConfig.isGateway) {
|
||||||
Text(
|
add(DetailHeroAction(
|
||||||
facts.joinToString(" • "),
|
icon = if (isMyShow) Icons.Default.BookmarkAdded else Icons.Default.BookmarkAdd,
|
||||||
color = SeriesMuted,
|
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
|
||||||
fontSize = 14.sp,
|
active = isMyShow,
|
||||||
maxLines = 1,
|
onClick = { onToggleMyShow(item, !isMyShow) },
|
||||||
overflow = TextOverflow.Ellipsis,
|
))
|
||||||
|
}
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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))
|
@Composable
|
||||||
Row(
|
private fun EpisodesPane(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
episodes: List<BaseItem>?,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
seasonEpisodes: List<BaseItem>,
|
||||||
) {
|
seasons: List<Int>,
|
||||||
SeriesDetailTabs(
|
selectedSeason: Int?,
|
||||||
selected = selectedSection,
|
remaining: Int,
|
||||||
episodeCount = episodes?.size,
|
loadFailed: Boolean,
|
||||||
castCount = item.cast.size,
|
seasonStripShown: Boolean,
|
||||||
onSelected = { selectedSectionKey = it.key },
|
seasonFocusRequesters: Map<Int, FocusRequester>,
|
||||||
)
|
firstEpisodeFocusRequester: FocusRequester,
|
||||||
if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) {
|
belowSeasons: FocusRequester,
|
||||||
Box(
|
aboveEpisodes: FocusRequester,
|
||||||
Modifier
|
tabFocusRequester: FocusRequester,
|
||||||
.padding(horizontal = 16.dp)
|
listState: LazyListState,
|
||||||
.width(1.dp)
|
emptyFocusRequester: FocusRequester,
|
||||||
.height(30.dp)
|
onSelectSeason: (Int) -> Unit,
|
||||||
.background(Color.White.copy(alpha = 0.12f)),
|
onPlay: (BaseItem) -> Unit,
|
||||||
)
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (seasonStripShown) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
items(seasons, key = { it }) { season ->
|
items(seasons, key = { it }) { season ->
|
||||||
@@ -224,79 +371,52 @@ fun SeriesDetailsOverlay(
|
|||||||
season = season,
|
season = season,
|
||||||
selected = season == selectedSeason,
|
selected = season == selectedSeason,
|
||||||
focusRequester = seasonFocusRequesters.getValue(season),
|
focusRequester = seasonFocusRequesters.getValue(season),
|
||||||
episodeFocusRequester = firstEpisode,
|
episodeFocusRequester = belowSeasons,
|
||||||
onClick = { selectedSeason = season },
|
tabFocusRequester = tabFocusRequester,
|
||||||
|
onClick = { onSelectSeason(season) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (seasons.size == 1) {
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
when {
|
|
||||||
selectedSection == SeriesDetailSection.CAST -> {
|
|
||||||
if (item.cast.isEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
"Cast information is not available for this show.",
|
text = seasonLabel(seasons.first()),
|
||||||
color = SeriesMuted,
|
color = DetailMutedText,
|
||||||
fontSize = 15.sp,
|
fontSize = 13.sp,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CastRail(
|
Spacer(Modifier.weight(1f))
|
||||||
people = item.cast,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
showTitle = false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
if (episodes != null && remaining > 0) {
|
||||||
episodes == null -> {
|
|
||||||
Text(
|
Text(
|
||||||
"Loading seasons…",
|
text = "$remaining unwatched",
|
||||||
color = SeriesQuiet,
|
color = DetailQuietText,
|
||||||
fontSize = 15.sp,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(start = 14.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadFailed -> {
|
|
||||||
Text(
|
|
||||||
"Episodes are temporarily unavailable.",
|
|
||||||
color = SeriesMuted,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
seasons.isEmpty() -> {
|
Spacer(Modifier.height(14.dp))
|
||||||
Text(
|
when {
|
||||||
"No episodes are available for this show.",
|
episodes == null -> DetailFocusablePane(emptyFocusRequester) { Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp) }
|
||||||
color = SeriesMuted,
|
loadFailed -> DetailFocusablePane(emptyFocusRequester) { Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp) }
|
||||||
fontSize = 15.sp,
|
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { Text("No episodes are available.", color = DetailQuietText, fontSize = 15.sp) }
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
else -> LazyColumn(
|
||||||
)
|
state = listState,
|
||||||
}
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
else -> {
|
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
|
||||||
LazyRow(
|
modifier = Modifier.fillMaxSize(),
|
||||||
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 ->
|
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
|
||||||
EpisodeCard(
|
EpisodeCard(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
onClick = { onPlay(episode) },
|
onClick = { onPlay(episode) },
|
||||||
seasonFocusRequester = seasonFocusRequesters[selectedSeason]
|
seasonFocusRequester = aboveEpisodes,
|
||||||
?: FocusRequester.Default,
|
isFirst = index == 0,
|
||||||
modifier = if (episode.id == seasonEpisodes.first().id) {
|
modifier = if (index == 0) {
|
||||||
Modifier
|
Modifier.focusRequester(firstEpisodeFocusRequester)
|
||||||
.focusRequester(firstEpisode)
|
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
@@ -305,95 +425,6 @@ fun SeriesDetailsOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -402,81 +433,93 @@ private fun SeasonChip(
|
|||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
episodeFocusRequester: FocusRequester,
|
episodeFocusRequester: FocusRequester,
|
||||||
|
tabFocusRequester: FocusRequester,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp)
|
val shape = RoundedCornerShape(7.dp)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
focused -> Color.White
|
focused -> Color.White
|
||||||
selected -> SeriesGreen
|
selected -> DetailAccent
|
||||||
else -> Color(0xCC20252A)
|
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)
|
.focusRequester(focusRequester)
|
||||||
.focusProperties { down = episodeFocusRequester }
|
.focusProperties {
|
||||||
|
up = tabFocusRequester
|
||||||
|
down = episodeFocusRequester
|
||||||
|
}
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (season == 0) "Specials" else "Season $season",
|
text = seasonLabel(season),
|
||||||
color = if (focused) Color.Black else Color.White,
|
color = if (focused) Color.Black else Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A minimal D-pad row: the focused episode becomes the unmistakable selection. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeCard(
|
private fun EpisodeCard(
|
||||||
episode: BaseItem,
|
episode: BaseItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
seasonFocusRequester: FocusRequester,
|
seasonFocusRequester: FocusRequester,
|
||||||
|
isFirst: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val imageUrl = remember(episode.id) {
|
val imageUrl = remember(episode.id) {
|
||||||
repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360)
|
repository.primaryUrl(episode, 480) ?: repository.backdropUrl(episode, 480)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
val progress = remember(episode.userData, episode.runTimeTicks) { playbackProgress(episode) }
|
||||||
val scale by animateFloatAsState(
|
val scale by animateFloatAsState(
|
||||||
targetValue = if (focused) 1.045f else 1f,
|
targetValue = if (focused) 1.01f else 1f,
|
||||||
animationSpec = tween(110),
|
animationSpec = tween(100),
|
||||||
label = "episode-card-focus",
|
label = "episode-card-focus",
|
||||||
)
|
)
|
||||||
Column(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(252.dp)
|
.fillMaxWidth()
|
||||||
|
.height(132.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = scale
|
scaleX = scale
|
||||||
scaleY = scale
|
scaleY = scale
|
||||||
|
translationY = if (focused) -1f else 0f
|
||||||
}
|
}
|
||||||
.zIndex(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 }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(bottom = 4.dp),
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.width(208.dp)
|
||||||
.aspectRatio(16f / 9f)
|
.fillMaxHeight()
|
||||||
.clip(shape)
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.background(Color(0xFF20252A)),
|
.background(Color(0xFF15181C)),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
@@ -488,87 +531,104 @@ private fun EpisodeCard(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.border(
|
.border(
|
||||||
width = if (focused) 3.dp else 1.dp,
|
width = 1.dp,
|
||||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
|
color = if (focused) Color.White.copy(alpha = 0.28f) else Color.White.copy(alpha = 0.10f),
|
||||||
shape = shape,
|
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) {
|
if (progress > 0f) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(4.dp)
|
.height(3.dp)
|
||||||
.background(Color.Black.copy(alpha = 0.65f)),
|
.background(Color.Black.copy(alpha = 0.65f)),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(progress)
|
.fillMaxWidth(progress)
|
||||||
.height(4.dp)
|
.height(3.dp)
|
||||||
.background(SeriesGreen),
|
.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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
Spacer(Modifier.width(16.dp))
|
||||||
Row(
|
Column(Modifier.weight(1f)) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier.padding(top = 9.dp),
|
episode.indexNumber?.let {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = listOfNotNull(
|
text = "$it.",
|
||||||
episode.indexNumber?.let { "$it." },
|
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
|
||||||
episode.name,
|
|
||||||
).joinToString(" "),
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(end = 7.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = episode.name,
|
||||||
|
color = if (focused) Color.White else DetailText,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
episode.runtimeMinutes?.let {
|
episode.runtimeMinutes?.let {
|
||||||
Text(
|
Text(
|
||||||
"${it}m",
|
text = "$it min",
|
||||||
color = SeriesQuiet,
|
color = if (focused) Color(0xFFE4E8EA) else DetailText,
|
||||||
fontSize = 12.sp,
|
fontSize = 13.sp,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
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 episode description available.",
|
text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.",
|
||||||
color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f),
|
color = if (focused) Color(0xFFC4CBD0) else DetailMutedText,
|
||||||
fontSize = 12.sp,
|
fontSize = 13.sp,
|
||||||
lineHeight = 16.sp,
|
lineHeight = 18.sp,
|
||||||
maxLines = 2,
|
maxLines = 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(top = 5.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val seriesEpisodeComparator =
|
|
||||||
compareBy<BaseItem>({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name })
|
|
||||||
|
|
||||||
internal fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
|
||||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
|
||||||
|
|
||||||
internal fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
|
||||||
if (season == null) emptyList()
|
|
||||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
|
||||||
|
|
||||||
internal fun defaultSeason(episodes: List<BaseItem>): Int? =
|
|
||||||
episodes.firstOrNull {
|
|
||||||
it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L)
|
|
||||||
}?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
|
||||||
|
|||||||
@@ -3,18 +3,15 @@ package com.ponzischeme89.memby.ui
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.Animatable
|
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.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
@@ -37,7 +32,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
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.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.ponzischeme89.memby.R
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||||
import com.ponzischeme89.memby.data.ServiceAlert
|
import com.ponzischeme89.memby.data.ServiceAlert
|
||||||
@@ -62,8 +57,13 @@ private val AlertAccent = Color(0xFF52B54B)
|
|||||||
private val AlertTitle = Color(0xFFF2F5F7)
|
private val AlertTitle = Color(0xFFF2F5F7)
|
||||||
private val AlertBody = Color(0xFFC3CBD2)
|
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
|
* 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(
|
AnimatedVisibility(
|
||||||
visible = alert != null,
|
visible = alert != null,
|
||||||
// In from above the frame, out the same way. Slower arriving than leaving:
|
// In from above the frame, out the same way, both unhurried: over a film the
|
||||||
// showing up should be noticed, going away should not.
|
// arrival is the intrusion, so it eases in rather than snapping down. Still
|
||||||
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
// slower arriving than leaving — showing up should be noticed, going away
|
||||||
fadeIn(tween(260)),
|
// should not.
|
||||||
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
|
enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } +
|
||||||
fadeOut(tween(220)),
|
fadeIn(tween(520)),
|
||||||
|
exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||||
|
fadeOut(tween(300)),
|
||||||
modifier = modifier.zIndex(8f),
|
modifier = modifier.zIndex(8f),
|
||||||
) {
|
) {
|
||||||
lastAlert?.let { AlertBanner(it) }
|
lastAlert?.let { AlertBanner(it) }
|
||||||
@@ -141,53 +143,52 @@ internal fun AlertBanner(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BannerHeight)
|
.height(BannerHeight)
|
||||||
.background(
|
.background(
|
||||||
// Darkest at the left where the text sits, easing off to the right so
|
// Near-black, and darkest at the left where the text sits. Over a
|
||||||
// the artwork behind the banner still shows through.
|
// film almost anything can be behind this, so the bar supplies its
|
||||||
|
// own contrast rather than relying on the picture underneath.
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xFF0E1418),
|
0f to Color(0xFF04070A),
|
||||||
0.55f to Color(0xF20E1418),
|
0.55f to Color(0xF504070A),
|
||||||
1f to Color(0xD9121A20),
|
1f to Color(0xE0070B0F),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.padding(horizontal = SafeAreaHorizontal),
|
.padding(horizontal = SafeAreaHorizontal),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
AlertPoster(posterUrl = alert.posterUrl)
|
AlertMark()
|
||||||
Spacer(Modifier.width(20.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
// The same short green rule used by the ten-minute reminder. It makes the
|
||||||
Row(
|
// alert feel like part of the player instead of a separate notification UI.
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent))
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
Spacer(Modifier.width(14.dp))
|
||||||
) {
|
|
||||||
LivePip()
|
|
||||||
Text(
|
Text(
|
||||||
"JUST AIRED",
|
alertLabel(alert.label),
|
||||||
color = AlertAccent,
|
color = AlertAccent,
|
||||||
fontSize = 13.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.6.sp,
|
letterSpacing = 1.6.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
Spacer(Modifier.width(12.dp))
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
// Keep the alert readable at TV distance without letting it overpower
|
|
||||||
// the screen content beneath it.
|
|
||||||
Text(
|
Text(
|
||||||
alert.title,
|
alert.title,
|
||||||
color = AlertTitle,
|
color = AlertTitle,
|
||||||
fontSize = 20.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
alert.message,
|
alert.message,
|
||||||
color = AlertBody,
|
color = AlertBody,
|
||||||
fontSize = 15.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
Spacer(Modifier.width(16.dp))
|
||||||
Spacer(Modifier.width(24.dp))
|
|
||||||
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ internal fun AlertBanner(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(2.dp)
|
.height(1.dp)
|
||||||
.background(
|
.background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
||||||
@@ -206,14 +207,25 @@ internal fun AlertBanner(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(22.dp)
|
.height(10.dp)
|
||||||
.background(
|
.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.
|
* 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
|
@Composable
|
||||||
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) {
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
val stroke = 3.dp.toPx()
|
val stroke = 2.dp.toPx()
|
||||||
val inset = stroke / 2f
|
val inset = stroke / 2f
|
||||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||||
drawArc(
|
drawArc(
|
||||||
@@ -257,60 +269,30 @@ private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
|||||||
Text(
|
Text(
|
||||||
secondsLeft.value.coerceAtLeast(0).toString(),
|
secondsLeft.value.coerceAtLeast(0).toString(),
|
||||||
color = AlertBody,
|
color = AlertBody,
|
||||||
fontSize = 16.sp,
|
fontSize = 9.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
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
|
@Composable
|
||||||
private fun AlertPoster(posterUrl: String?) {
|
private fun AlertMark() {
|
||||||
val shape = RoundedCornerShape(6.dp)
|
Image(
|
||||||
Box(
|
painter = painterResource(R.drawable.emby_logo),
|
||||||
modifier = Modifier
|
|
||||||
.width(50.dp)
|
|
||||||
.height(74.dp)
|
|
||||||
.clip(shape)
|
|
||||||
.background(Color(0xFF18222B)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (posterUrl != null) {
|
|
||||||
AsyncImage(
|
|
||||||
model = posterUrl,
|
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.width(30.dp)
|
||||||
|
.height(26.dp),
|
||||||
)
|
)
|
||||||
} 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
||||||
@@ -327,7 +309,38 @@ private fun ServiceAlertBannerPreview() {
|
|||||||
id = "sonarr:7:42:aired",
|
id = "sonarr:7:42:aired",
|
||||||
title = "Northbound",
|
title = "Northbound",
|
||||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
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",
|
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 " +
|
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||||
"aired at 10:30 PM and is downloading now.",
|
"aired at 10:30 PM and is downloading now.",
|
||||||
posterUrl = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -40,20 +42,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||||
|
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
|
||||||
import com.ponzischeme89.memby.update.UpdateChecker
|
import com.ponzischeme89.memby.update.UpdateChecker
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -63,7 +71,8 @@ private val UpdateBody = Color(0xFFAEB7BF)
|
|||||||
private val UpdateFaint = Color(0xFFA2ADB5)
|
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
|
* 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
|
* 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,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val checker = remember { UpdateChecker(context) }
|
val checker = remember { UpdateChecker(context) }
|
||||||
|
|
||||||
var installing by remember { mutableStateOf(false) }
|
var installing by remember { mutableStateOf(false) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(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 primaryFocus = remember { FocusRequester() }
|
||||||
|
val laterFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
|
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
|
||||||
|
|
||||||
// Swallow Back entirely while an update is required. For an optional prompt, Back is
|
// Swallow Back entirely while an update is required. For an optional prompt, Back is
|
||||||
@@ -120,13 +169,7 @@ fun UpdateScreen(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
||||||
.background(Color(0xFF0B0E11))
|
.background(Color(0xFF0B0E11)),
|
||||||
// Consumes clicks so nothing behind can be reached.
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = {},
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
|
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.
|
// easy to back out of by accident.
|
||||||
"Choose Update now — Memby downloads the new version, then your TV asks you " +
|
"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 " +
|
"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,
|
color = UpdateFaint,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -222,21 +265,23 @@ fun UpdateScreen(
|
|||||||
label = if (installing) "Downloading…" else "Update now",
|
label = if (installing) "Downloading…" else "Update now",
|
||||||
primary = true,
|
primary = true,
|
||||||
enabled = !installing,
|
enabled = !installing,
|
||||||
onClick = {
|
onClick = ::beginInstall,
|
||||||
if (installing) return@UpdateButton
|
modifier = Modifier
|
||||||
installing = true
|
.focusRequester(primaryFocus)
|
||||||
message = null
|
.focusProperties {
|
||||||
scope.launch {
|
if (!update.isMandatory) right = laterFocus
|
||||||
val result = checker.downloadAndInstall(update.downloadUrl, token = "")
|
|
||||||
installing = false
|
|
||||||
message = result.exceptionOrNull()?.message
|
|
||||||
?: "Opening the installer…"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(primaryFocus),
|
|
||||||
)
|
)
|
||||||
if (!update.isMandatory) {
|
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),
|
color = UpdateFaint.copy(alpha = 0.75f),
|
||||||
fontSize = 13.sp,
|
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),
|
shape = RoundedCornerShape(10.dp),
|
||||||
)
|
)
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.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() })
|
.focusable(interactionSource = remember { MutableInteractionSource() })
|
||||||
.clickable(enabled = enabled, onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.padding(horizontal = 30.dp, vertical = 13.dp),
|
.padding(horizontal = 30.dp, vertical = 13.dp),
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}"
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ponzischeme89.memby.ui.detail
|
package com.ponzischeme89.memby.ui.detail
|
||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +16,15 @@ import java.util.Locale
|
|||||||
/** Emby stores durations and positions as 100-ns ticks. */
|
/** Emby stores durations and positions as 100-ns ticks. */
|
||||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
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. */
|
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
|
||||||
fun formatRuntime(minutes: Int): String {
|
fun formatRuntime(minutes: Int): String {
|
||||||
val hours = minutes / 60
|
val hours = minutes / 60
|
||||||
@@ -52,27 +62,100 @@ fun remainingLabel(item: BaseItem): String? {
|
|||||||
return "${formatPosition(left)} left"
|
return "${formatPosition(left)} left"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
|
/**
|
||||||
fun movieFacts(item: BaseItem): List<String> = 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<String> = buildList {
|
||||||
item.productionYear?.let { add(it.toString()) }
|
item.productionYear?.let { add(it.toString()) }
|
||||||
|
if (seasonCount > 0) {
|
||||||
|
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||||
|
} else {
|
||||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||||
|
}
|
||||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
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. */
|
/** "8.9" for the score beside the title, or null when Emby has no community rating. */
|
||||||
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
|
fun ratingLabel(item: BaseItem): String? =
|
||||||
item.productionYear?.let { add(it.toString()) }
|
item.communityRating?.let { String.format(Locale.US, "%.1f", it) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A label/value pair for the quiet technical area. */
|
/** A label/value pair for the quiet technical area. */
|
||||||
data class TechnicalSpec(val label: String, val value: String)
|
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<TechnicalSpec> = 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<String>::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<String>::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<DetailTab> = 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>): DetailTab =
|
||||||
|
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
|
* 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.
|
* of the headline because none of them decide what to watch.
|
||||||
@@ -92,7 +175,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
|||||||
stream.codec?.uppercase(Locale.US),
|
stream.codec?.uppercase(Locale.US),
|
||||||
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
||||||
).takeIf(List<String>::isNotEmpty)?.let {
|
).takeIf(List<String>::isNotEmpty)?.let {
|
||||||
add(TechnicalSpec("Codec", it.joinToString(" · ")))
|
add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio?.let { stream ->
|
audio?.let { stream ->
|
||||||
@@ -101,7 +184,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
|||||||
stream.channels?.let(::channelLabel),
|
stream.channels?.let(::channelLabel),
|
||||||
stream.language?.takeIf(String::isNotBlank),
|
stream.language?.takeIf(String::isNotBlank),
|
||||||
).takeIf(List<String>::isNotEmpty)?.let {
|
).takeIf(List<String>::isNotEmpty)?.let {
|
||||||
add(TechnicalSpec("Audio", it.joinToString(" · ")))
|
add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (subtitles > 0) {
|
if (subtitles > 0) {
|
||||||
@@ -114,14 +197,19 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolutionSuffix(width: Int): String = when {
|
private fun resolutionSuffix(width: Int): String = when {
|
||||||
width >= 3_400 -> " (4K)"
|
width >= UHD_MIN_WIDTH -> " (4K)"
|
||||||
width >= 2_500 -> " (1440p)"
|
width >= 2_500 -> " (1440p)"
|
||||||
width >= 1_800 -> " (1080p)"
|
width >= 1_800 -> " (1080p)"
|
||||||
width >= 1_200 -> " (720p)"
|
width >= 1_200 -> " (720p)"
|
||||||
else -> ""
|
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)
|
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
|
||||||
return when {
|
return when {
|
||||||
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
||||||
@@ -139,6 +227,26 @@ private fun channelLabel(channels: Int): String = when (channels) {
|
|||||||
else -> "${channels}ch"
|
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<BaseItem>): 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<BaseItem>({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }),
|
||||||
|
) ?: return null
|
||||||
|
return FranchiseStart(collection, first)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Series structure
|
// Series structure
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,7 +304,7 @@ fun episodeLabel(episode: BaseItem): String? {
|
|||||||
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
||||||
fun episodeHeadline(episode: BaseItem): String =
|
fun episodeHeadline(episode: BaseItem): String =
|
||||||
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
|
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
|
* The primary button. Series pass their next episode so the button can name it; a movie
|
||||||
|
|||||||
@@ -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<String, DetailPosition>(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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ internal data class PlaybackFailure(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val detail: String,
|
val detail: String,
|
||||||
val canAutoRetry: Boolean,
|
val canAutoRetry: Boolean,
|
||||||
|
val requiresTranscode: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||||
@@ -45,17 +46,26 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
|||||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||||
-> PlaybackFailure(
|
-> PlaybackFailure(
|
||||||
title = "Video format not supported",
|
title = "Video format not supported",
|
||||||
detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.",
|
detail = "This TV couldn’t decode the original format. Memby will request a compatible stream.",
|
||||||
canAutoRetry = false,
|
canAutoRetry = true,
|
||||||
|
requiresTranscode = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||||
-> PlaybackFailure(
|
-> PlaybackFailure(
|
||||||
title = "Video file couldn’t be read",
|
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,
|
canAutoRetry = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
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.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.unit.sp
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import androidx.core.graphics.get
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -88,13 +85,13 @@ import androidx.tv.material3.Button
|
|||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.imageLoader
|
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.request.SuccessResult
|
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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 com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -157,6 +154,7 @@ fun ScreensaverContent(
|
|||||||
ringColor = ringColorFromHex(settings?.ringColorHex),
|
ringColor = ringColorFromHex(settings?.ringColorHex),
|
||||||
embyServerId = settings?.serverId,
|
embyServerId = settings?.serverId,
|
||||||
warmBackdropUrl = settings?.lastBackdropUrl,
|
warmBackdropUrl = settings?.lastBackdropUrl,
|
||||||
|
hideWatchedMovies = settings?.hideWatchedMovies == true,
|
||||||
startupMessage = startupMessage,
|
startupMessage = startupMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -171,6 +169,7 @@ private fun Slideshow(
|
|||||||
ringColor: Color,
|
ringColor: Color,
|
||||||
embyServerId: String?,
|
embyServerId: String?,
|
||||||
warmBackdropUrl: String?,
|
warmBackdropUrl: String?,
|
||||||
|
hideWatchedMovies: Boolean,
|
||||||
startupMessage: String?,
|
startupMessage: String?,
|
||||||
) {
|
) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
@@ -200,12 +199,12 @@ private fun Slideshow(
|
|||||||
val slideProgress = remember { mutableFloatStateOf(0f) }
|
val slideProgress = remember { mutableFloatStateOf(0f) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
LaunchedEffect(reloadKey) {
|
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||||
loading = true
|
loading = true
|
||||||
loadError = null
|
loadError = null
|
||||||
runCatching { repo.getScreensaverItems() }
|
runCatching { repo.getScreensaverItems() }
|
||||||
.onSuccess { fetched ->
|
.onSuccess { fetched ->
|
||||||
val queue = fetched.shuffled()
|
val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled()
|
||||||
// The lightweight startup request may already be on-screen. Keep that
|
// The lightweight startup request may already be on-screen. Keep that
|
||||||
// exact item at the front rather than swapping through several results
|
// exact item at the front rather than swapping through several results
|
||||||
// as competing requests finish; the rest of the random queue follows it.
|
// 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.
|
// 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.
|
// This runs alongside it and normally wins on a cold launch.
|
||||||
LaunchedEffect(reloadKey) {
|
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||||
runCatching { repo.getStartupBackdropMovie() }
|
runCatching { repo.getStartupBackdropMovie() }
|
||||||
.onSuccess { movie ->
|
.onSuccess { movie ->
|
||||||
if (movie != null && items.isEmpty()) {
|
if (
|
||||||
|
movie != null &&
|
||||||
|
visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() &&
|
||||||
|
items.isEmpty()
|
||||||
|
) {
|
||||||
items = listOf(movie)
|
items = listOf(movie)
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -244,7 +247,10 @@ private fun Slideshow(
|
|||||||
loadingMore = true
|
loadingMore = true
|
||||||
toast = "Finding more from your library…"
|
toast = "Finding more from your library…"
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList())
|
val more = visibleWithWatchedPreference(
|
||||||
|
runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()),
|
||||||
|
hideWatchedMovies,
|
||||||
|
)
|
||||||
if (more.isNotEmpty()) {
|
if (more.isNotEmpty()) {
|
||||||
val existing = items.mapTo(HashSet()) { it.id }
|
val existing = items.mapTo(HashSet()) { it.id }
|
||||||
val fresh = more.filter { it.id !in existing }
|
val fresh = more.filter { it.id !in existing }
|
||||||
@@ -616,7 +622,6 @@ private fun Slideshow(
|
|||||||
|
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
SettingsSheet(
|
SettingsSheet(
|
||||||
editableServer = true,
|
|
||||||
onClose = { settingsOpen = false },
|
onClose = { settingsOpen = false },
|
||||||
onInstallerLaunched = onExit,
|
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
|
@Composable
|
||||||
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
|
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
|
||||||
val chips = buildList {
|
val chips = buildList {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import coil.imageLoader
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.FocusScaleContainer
|
||||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ fun SearchScreen(
|
|||||||
state.errorMessage != null && state.results.isEmpty() -> true
|
state.errorMessage != null && state.results.isEmpty() -> true
|
||||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||||
else -> state.results.isNotEmpty()
|
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||||
@@ -237,6 +238,7 @@ fun SearchScreen(
|
|||||||
},
|
},
|
||||||
onItemSelected = onItemSelected,
|
onItemSelected = onItemSelected,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
|
onRequest = viewModel::request,
|
||||||
onSuggestionSelected = viewModel::onQueryChanged,
|
onSuggestionSelected = viewModel::onQueryChanged,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
@@ -588,6 +590,7 @@ private fun ResultsPane(
|
|||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||||
onSuggestionSelected: (String) -> Unit,
|
onSuggestionSelected: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -644,6 +647,13 @@ private fun ResultsPane(
|
|||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
keyboardReturn = keyboardReturn,
|
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)
|
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
|
||||||
else -> ResultsGrid(
|
else -> ResultsGrid(
|
||||||
items = items,
|
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(
|
private val GenreColors = listOf(
|
||||||
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
||||||
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
||||||
@@ -861,6 +919,7 @@ private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean)
|
|||||||
val message = when {
|
val message = when {
|
||||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
||||||
state.isLoading -> "Searching…"
|
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."
|
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))
|
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.ponzischeme89.memby.data.EmbyRepository
|
import com.ponzischeme89.memby.data.EmbyRepository
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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". */
|
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||||
val hasSearched: Boolean = false,
|
val hasSearched: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
||||||
|
val requestLookupLoading: Boolean = false,
|
||||||
|
val requestingCandidateKey: String? = null,
|
||||||
|
val requestMessage: String? = null,
|
||||||
) {
|
) {
|
||||||
/** The query is long enough to search but nothing came back. */
|
/** The query is long enough to search but nothing came back. */
|
||||||
val isEmptyResult: Boolean
|
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. */
|
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||||
fun onQueryChanged(query: String) {
|
fun onQueryChanged(query: String) {
|
||||||
// The visible field updates immediately; only the *search* is debounced.
|
// 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
|
queryFlow.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +123,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
fun clearQuery() {
|
fun clearQuery() {
|
||||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||||
// debounced empty-query transition is pending.
|
// 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 = ""
|
queryFlow.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +141,38 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
viewModelScope.launch { runSearch(term) }
|
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.
|
* 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.
|
* 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
|
// Back to the discovery state, but the previous results are dropped rather
|
||||||
// than left behind a shorter query they no longer match.
|
// than left behind a shorter query they no longer match.
|
||||||
_state.update {
|
_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
|
return
|
||||||
}
|
}
|
||||||
@@ -158,6 +212,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
|
if (cached.isEmpty()) loadRequestCandidates(term)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,13 +221,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
runCatching { repository.search(term) }
|
runCatching { repository.search(term) }
|
||||||
.onSuccess { items ->
|
.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
|
cache[term] = ranked
|
||||||
rememberQuery(term)
|
rememberQuery(term)
|
||||||
viewModelScope.launch { repository.recordSearch(term) }
|
viewModelScope.launch { repository.recordSearch(term) }
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
|
if (ranked.isEmpty()) loadRequestCandidates(term)
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
// A cancelled search is the normal case while typing, not a failure.
|
// 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) {
|
private fun rememberQuery(term: String) {
|
||||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
||||||
recentQueries.addFirst(term)
|
recentQueries.addFirst(term)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 = " · "
|
||||||
@@ -9,11 +9,14 @@ import androidx.tv.material3.LocalTextStyle
|
|||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.darkColorScheme
|
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(
|
private val EmbyColors = darkColorScheme(
|
||||||
primary = androidx.compose.ui.graphics.Color(0xFF52B54B),
|
primary = MembyAccent,
|
||||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||||
surface = androidx.compose.ui.graphics.Color(0xFF101418),
|
surface = MembySurfaceRaised,
|
||||||
background = androidx.compose.ui.graphics.Color(0xFF0B0E11),
|
background = MembySurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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<GatewayUpdate?> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package com.ponzischeme89.memby.update
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -12,6 +15,8 @@ import kotlinx.serialization.json.Json
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/** Result of a "check for updates" against the configured Gitea release. */
|
/** 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
|
* 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.
|
* to that settings screen and return a message asking them to retry.
|
||||||
*/
|
*/
|
||||||
suspend fun downloadAndInstall(apkUrl: String, token: String): Result<Unit> =
|
suspend fun downloadAndInstall(
|
||||||
|
apkUrl: String,
|
||||||
|
token: String,
|
||||||
|
expectedVersion: String = "",
|
||||||
|
expectedSHA256: String = "",
|
||||||
|
expectedSizeBytes: Long = 0,
|
||||||
|
): Result<Unit> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Gate on the install-unknown-apps permission before spending a download.
|
// Gate on the install-unknown-apps permission before spending a download.
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
@@ -170,21 +181,75 @@ class UpdateChecker(private val context: Context) {
|
|||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
return@withContext Result.failure(
|
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 {
|
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 {
|
val req = Request.Builder().url(apkUrl).apply {
|
||||||
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
|
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
|
||||||
}.build()
|
}.build()
|
||||||
http.newCall(req).execute().use { resp ->
|
http.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) error("Download failed (${resp.code}).")
|
if (!resp.isSuccessful) error("Download failed (${resp.code}).")
|
||||||
val body = resp.body ?: error("Empty download.")
|
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(
|
val uri = FileProvider.getUriForFile(
|
||||||
context, "${context.packageName}.fileprovider", file,
|
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<String> {
|
||||||
|
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]. */
|
/** True when [remote] parses to a strictly higher version than [installed]. */
|
||||||
private fun isNewer(remote: String, installed: String): Boolean {
|
private fun isNewer(remote: String, installed: String): Boolean {
|
||||||
val r = parseVersion(remote)
|
val r = parseVersion(remote)
|
||||||
@@ -212,8 +322,14 @@ class UpdateChecker(private val context: Context) {
|
|||||||
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
|
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
|
||||||
|
|
||||||
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
|
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.
|
* 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
|
* Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ package com.ponzischeme89.memby.update
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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
|
* The normal entry point runs the public update check and then reuses the existing saved
|
||||||
* TV that can leave the old Dream surface black. Android sends this broadcast to the
|
* session. Nothing in this recovery path clears app data, profiles, or credentials.
|
||||||
* newly installed package, giving us a chance to present a real UI instead.
|
|
||||||
*/
|
*/
|
||||||
class UpdateRecoveryReceiver : BroadcastReceiver() {
|
class UpdateRecoveryReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||||
|
|
||||||
val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply {
|
val launch = Intent(context, MainActivity::class.java).apply {
|
||||||
addFlags(
|
addFlags(
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<gradient
|
<gradient
|
||||||
android:angle="90"
|
android:angle="90"
|
||||||
android:endColor="#F2000000"
|
android:centerColor="#28000000"
|
||||||
android:startColor="#00000000"
|
android:endColor="#00000000"
|
||||||
|
android:startColor="#B8000000"
|
||||||
android:type="linear" />
|
android:type="linear" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="0"
|
||||||
|
android:endColor="#00000000"
|
||||||
|
android:startColor="#A607090B"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FF20252A" />
|
||||||
|
<corners android:radius="10dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#30FFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="0"
|
||||||
|
android:endColor="#00000000"
|
||||||
|
android:startColor="#EB07090B"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="11dp" />
|
||||||
|
<gradient
|
||||||
|
android:angle="0"
|
||||||
|
android:startColor="#FF223139"
|
||||||
|
android:centerColor="#FF182126"
|
||||||
|
android:endColor="#FF0D1114" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="11dp" />
|
||||||
|
<solid android:color="#00000000" />
|
||||||
|
<stroke android:width="1dp" android:color="#38FFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="11dp" />
|
||||||
|
<gradient
|
||||||
|
android:angle="0"
|
||||||
|
android:startColor="#F2070A0C"
|
||||||
|
android:centerColor="#B8070A0C"
|
||||||
|
android:endColor="#33070A0C" />
|
||||||
|
</shape>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
|
||||||
<gradient
|
<gradient
|
||||||
android:angle="315"
|
android:angle="315"
|
||||||
android:centerColor="#FF0D1114"
|
android:centerColor="#FF0D1114"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
<solid android:color="#00000000" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="#4DFFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Near-black and barely there: this rolls up over somebody's film, so it reads as a
|
||||||
|
broadcast lower third rather than as a dialog. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#E60A0D10" />
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
</shape>
|
||||||
@@ -18,6 +18,18 @@
|
|||||||
app:surface_type="surface_view"
|
app:surface_type="surface_view"
|
||||||
app:use_controller="true" />
|
app:use_controller="true" />
|
||||||
|
|
||||||
|
<!-- Channel + programme identity. It is owned by the activity rather than the
|
||||||
|
controller, so it can appear briefly without opening the transport controls. -->
|
||||||
|
<include layout="@layout/player_playback_identity" />
|
||||||
|
|
||||||
|
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
|
||||||
|
if the two ever coincide: what comes next matters more than how long is left. -->
|
||||||
|
<include layout="@layout/player_time_remaining" />
|
||||||
|
|
||||||
|
<!-- Finale context has priority over the ordinary start cue and is physically stacked
|
||||||
|
above it. Both remain non-focusable lower thirds. -->
|
||||||
|
<include layout="@layout/player_season_finale" />
|
||||||
|
|
||||||
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
||||||
nothing to say about what comes next. -->
|
nothing to say about what comes next. -->
|
||||||
<include layout="@layout/player_next_up_banner" />
|
<include layout="@layout/player_next_up_banner" />
|
||||||
@@ -29,6 +41,17 @@
|
|||||||
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
||||||
<include layout="@layout/player_cast_overlay" />
|
<include layout="@layout/player_cast_overlay" />
|
||||||
|
|
||||||
|
<!-- Service alerts, the same bar the launcher shows. Declared before the loading and
|
||||||
|
error overlays so those cover it: a notice about the library is not what someone
|
||||||
|
staring at a failed stream needs. Never focusable, so it cannot take the remote
|
||||||
|
away from the transport controls. -->
|
||||||
|
<androidx.compose.ui.platform.ComposeView
|
||||||
|
android:id="@+id/player_service_alerts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top"
|
||||||
|
android:focusable="false" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/playback_loading"
|
android:id="@+id/playback_loading"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -66,7 +89,8 @@
|
|||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
|
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
|
||||||
|
here at full-screen size after the countdown. No second player or stream is used. -->
|
||||||
<include layout="@layout/player_preroll" />
|
<include layout="@layout/player_preroll" />
|
||||||
|
|
||||||
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
||||||
|
|||||||
@@ -14,24 +14,31 @@
|
|||||||
android:background="@android:color/transparent" />
|
android:background="@android:color/transparent" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
android:layout_width="match_parent"
|
android:layout_width="720dp"
|
||||||
android:layout_height="210dp"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="top"
|
android:layout_gravity="start"
|
||||||
android:background="@drawable/player_osd_top_gradient" />
|
android:background="@drawable/player_osd_left_gradient" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="300dp"
|
android:layout_height="230dp"
|
||||||
android:layout_gravity="bottom"
|
android:layout_gravity="bottom"
|
||||||
android:background="@drawable/player_osd_bottom_gradient" />
|
android:background="@drawable/player_osd_bottom_gradient" />
|
||||||
|
|
||||||
|
<!-- Pause is a deliberate browsing moment, rather than just the ordinary OSD frozen
|
||||||
|
over a frame. It carries the movie context while the transport row below remains
|
||||||
|
the single, familiar place to resume or seek. -->
|
||||||
|
<include layout="@layout/player_pause_overlay" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/player_now_playing_group"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="140dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="top|start"
|
android:layout_gravity="top|start"
|
||||||
android:layout_marginStart="48dp"
|
android:layout_marginStart="48dp"
|
||||||
android:layout_marginTop="30dp"
|
android:layout_marginTop="30dp"
|
||||||
android:gravity="center_vertical"
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -60,6 +67,8 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
android:maxWidth="600dp"
|
android:maxWidth="600dp"
|
||||||
|
android:includeFontPadding="true"
|
||||||
|
android:lineSpacingExtra="2dp"
|
||||||
android:textColor="#FFFFFFFF"
|
android:textColor="#FFFFFFFF"
|
||||||
android:textSize="28sp"
|
android:textSize="28sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/player_pause_overlay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:focusable="false"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="820dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/player_pause_scrim" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="760dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="start|center_vertical"
|
||||||
|
android:layout_marginStart="48dp"
|
||||||
|
android:layout_marginBottom="62dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/player_pause_poster"
|
||||||
|
android:layout_width="164dp"
|
||||||
|
android:layout_height="246dp"
|
||||||
|
android:background="@drawable/player_pause_poster_background"
|
||||||
|
android:clipToOutline="true"
|
||||||
|
android:contentDescription="@string/player_pause_poster"
|
||||||
|
android:outlineProvider="background"
|
||||||
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="30dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:letterSpacing="0.18"
|
||||||
|
android:text="@string/player_paused"
|
||||||
|
android:textColor="#FF6BCB63"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_pause_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="34sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_pause_overview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="14dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:lineSpacingExtra="3dp"
|
||||||
|
android:maxLines="5"
|
||||||
|
android:textColor="#DDE7EBEE"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="18dp"
|
||||||
|
android:text="@string/player_pause_resume_hint"
|
||||||
|
android:textColor="#B8FFFFFF"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/player_playback_identity"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="54dp"
|
||||||
|
android:layout_gravity="start|top"
|
||||||
|
android:layout_marginStart="48dp"
|
||||||
|
android:layout_marginTop="34dp"
|
||||||
|
android:alpha="0"
|
||||||
|
android:focusable="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="42dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:contentDescription="@string/player_preroll_brand_logo"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/emby_logo" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_playback_identity_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:maxWidth="520dp"
|
||||||
|
android:shadowColor="#E0000000"
|
||||||
|
android:shadowDx="0"
|
||||||
|
android:shadowDy="2"
|
||||||
|
android:shadowRadius="5"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="25sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -5,114 +5,152 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#FF050708"
|
android:background="#FF050708"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:clipChildren="false"
|
||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingStart="64dp"
|
|
||||||
android:paddingTop="54dp"
|
|
||||||
android:paddingEnd="64dp"
|
|
||||||
android:paddingBottom="54dp">
|
|
||||||
|
|
||||||
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
|
|
||||||
virtually free and lets the real programme buffer beneath the overlay. -->
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_marginEnd="52dp"
|
|
||||||
android:layout_weight="1.62"
|
|
||||||
android:background="@drawable/player_preroll_video_background">
|
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="74dp"
|
android:id="@+id/player_preroll_brand"
|
||||||
android:layout_height="64dp"
|
android:layout_width="58dp"
|
||||||
android:layout_gravity="start|bottom"
|
android:layout_height="50dp"
|
||||||
android:layout_marginStart="28dp"
|
android:layout_gravity="start|top"
|
||||||
android:layout_marginBottom="25dp"
|
android:layout_marginStart="44dp"
|
||||||
android:alpha="0.24"
|
android:layout_marginTop="28dp"
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@string/player_preroll_brand_logo"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:src="@drawable/emby_logo" />
|
android:src="@drawable/emby_logo" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/player_preroll_countdown"
|
android:id="@+id/player_preroll_content"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="44dp"
|
||||||
|
android:paddingTop="84dp"
|
||||||
|
android:paddingEnd="44dp"
|
||||||
|
android:paddingBottom="22dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="224dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/player_preroll_video_host"
|
||||||
|
android:layout_width="398dp"
|
||||||
|
android:layout_height="224dp"
|
||||||
|
android:layout_marginEnd="38dp"
|
||||||
|
android:background="@drawable/player_preroll_video_background"
|
||||||
|
android:clipToOutline="true"
|
||||||
|
android:foreground="@drawable/player_preroll_video_frame"
|
||||||
|
android:outlineProvider="background">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/player_preroll_countdown_container"
|
||||||
|
android:layout_width="80dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="end|bottom"
|
android:layout_gravity="end|bottom"
|
||||||
android:layout_marginEnd="28dp"
|
android:layout_marginEnd="18dp"
|
||||||
android:layout_marginBottom="28dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:background="@drawable/player_status_background"
|
android:gravity="center_horizontal"
|
||||||
android:paddingStart="18dp"
|
android:orientation="vertical">
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingEnd="18dp"
|
<TextView
|
||||||
android:paddingBottom="10dp"
|
android:layout_width="wrap_content"
|
||||||
android:text="@string/player_preroll_countdown_initial"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#FFFFFFFF"
|
android:layout_marginBottom="5dp"
|
||||||
android:textSize="15sp"
|
android:text="@string/player_preroll_starting_in"
|
||||||
|
android:textAllCaps="true"
|
||||||
|
android:textColor="#CCFFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.ponzischeme89.memby.ui.player.PrerollCountdownView
|
||||||
|
android:id="@+id/player_preroll_countdown"
|
||||||
|
android:layout_width="66dp"
|
||||||
|
android:layout_height="66dp"
|
||||||
|
android:contentDescription="@string/player_preroll_countdown_initial" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/player_preroll_schedule_panel"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="NOW"
|
||||||
|
android:textColor="#FF64C85A"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_now_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Coming up"
|
android:layout_marginTop="7dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:text="Northbound · The Crossing"
|
||||||
android:textColor="#FFFFFFFF"
|
android:textColor="#FFFFFFFF"
|
||||||
android:textSize="30sp"
|
android:textSize="26sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_now_metadata"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="5dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="From your Sonarr calendar"
|
android:text="S02E04 · 48 mins"
|
||||||
android:textColor="#FF8E979D"
|
android:textColor="#FFD0D6DB"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_now_overview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:text="The crew follows a signal beyond the last charted crossing and finds the shore waiting for them."
|
||||||
|
android:textColor="#FFB1BAC1"
|
||||||
android:textSize="13sp" />
|
android:textSize="13sp" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/player_preroll_today_label"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="25dp"
|
|
||||||
android:text="AIRING TODAY"
|
|
||||||
android:textAllCaps="true"
|
|
||||||
android:textColor="#FF55B94D"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/player_preroll_today"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:orientation="vertical" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/player_preroll_week_label"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="22dp"
|
|
||||||
android:text="THIS WEEK"
|
|
||||||
android:textAllCaps="true"
|
|
||||||
android:textColor="#FF8E979D"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/player_preroll_week"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:orientation="vertical" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="UPCOMING"
|
||||||
|
android:textColor="#FF64C85A"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<GridLayout
|
||||||
|
android:id="@+id/player_preroll_calendar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="170dp"
|
||||||
|
android:layout_marginTop="5dp"
|
||||||
|
android:columnCount="4"
|
||||||
|
android:rowCount="2"
|
||||||
|
android:useDefaultMargins="false" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="80dp"
|
||||||
|
android:background="@drawable/player_preroll_card_background"
|
||||||
|
android:clipToOutline="true"
|
||||||
|
android:outlineProvider="background">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/player_preroll_card_artwork"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/player_preroll_card_scrim" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="9dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_card_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAllCaps="true"
|
||||||
|
android:textColor="#FF64C85A"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_card_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_preroll_card_detail"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#FFD0D6DB"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/player_preroll_card_frame" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Finale context is a separate, higher-priority lower third. It sits above the normal
|
||||||
|
start/time cue so both can be read together and never takes focus from playback. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/player_season_finale"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|start"
|
||||||
|
android:layout_marginStart="52dp"
|
||||||
|
android:layout_marginBottom="120dp"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/time_remaining_cue_background"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingEnd="18dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="3dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="#FFFFB454" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="30dp"
|
||||||
|
android:layout_height="27dp"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/emby_logo" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_season_finale_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:letterSpacing="0.16"
|
||||||
|
android:paddingTop="9dp"
|
||||||
|
android:paddingBottom="9dp"
|
||||||
|
android:text="@string/player_season_finale_label"
|
||||||
|
android:textColor="#FFFFC778"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_season_finale_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingTop="9dp"
|
||||||
|
android:paddingBottom="9dp"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Signal Hill · Season 2 finale" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The ten-minute lower third. A single non-focusable strip pinned to the lower left,
|
||||||
|
inside the TV overscan margin, that rolls up from below its own baseline and rolls
|
||||||
|
back down again. It never takes the remote and never covers the transport controls. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/player_time_remaining"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|start"
|
||||||
|
android:layout_marginStart="52dp"
|
||||||
|
android:layout_marginBottom="72dp"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/time_remaining_cue_background"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="0dp"
|
||||||
|
android:paddingEnd="18dp">
|
||||||
|
|
||||||
|
<!-- Keep the cue recognisably Memby even when it appears independently of the
|
||||||
|
transport OSD. The accent rule still distinguishes ordinary timing cues. -->
|
||||||
|
<View
|
||||||
|
android:layout_width="3dp"
|
||||||
|
android:layout_height="26dp"
|
||||||
|
android:background="#FF69CD61" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="27dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/emby_logo" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_time_remaining_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:letterSpacing="0.16"
|
||||||
|
android:paddingTop="7dp"
|
||||||
|
android:paddingBottom="7dp"
|
||||||
|
android:text="@string/player_time_remaining_label"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_time_remaining_value"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:paddingTop="7dp"
|
||||||
|
android:paddingBottom="7dp"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="10 mins" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
<item quantity="other">Reconnecting in %1$d seconds…</item>
|
<item quantity="other">Reconnecting in %1$d seconds…</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="player_now_playing">NOW PLAYING</string>
|
<string name="player_now_playing">NOW PLAYING</string>
|
||||||
|
<string name="player_paused">PAUSED</string>
|
||||||
|
<string name="player_pause_poster">Movie poster</string>
|
||||||
|
<string name="player_pause_resume_hint">Press OK to resume</string>
|
||||||
|
<string name="player_pause_overview_fallback">No description is available for this title.</string>
|
||||||
<string name="player_position_separator">/</string>
|
<string name="player_position_separator">/</string>
|
||||||
<string name="player_loading_duration">Loading duration…</string>
|
<string name="player_loading_duration">Loading duration…</string>
|
||||||
<string name="player_live">Live</string>
|
<string name="player_live">Live</string>
|
||||||
@@ -29,12 +33,24 @@
|
|||||||
<string name="player_text_size">TEXT SIZE</string>
|
<string name="player_text_size">TEXT SIZE</string>
|
||||||
<string name="player_back_to_close">BACK · CLOSE</string>
|
<string name="player_back_to_close">BACK · CLOSE</string>
|
||||||
<string name="player_ends_at">Ends at %1$s</string>
|
<string name="player_ends_at">Ends at %1$s</string>
|
||||||
<string name="player_preroll_countdown_initial">Starting in 5 seconds…</string>
|
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
|
||||||
|
<string name="player_preroll_starting_in">Starting in</string>
|
||||||
|
<string name="player_preroll_brand_logo">Memby logo</string>
|
||||||
<plurals name="player_preroll_countdown">
|
<plurals name="player_preroll_countdown">
|
||||||
<item quantity="one">Starting in %1$d second…</item>
|
<item quantity="one">Starting in %1$d second…</item>
|
||||||
<item quantity="other">Starting in %1$d seconds…</item>
|
<item quantity="other">Starting in %1$d seconds…</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="player_preroll_starting">Starting now…</string>
|
<string name="player_preroll_starting">Starting now…</string>
|
||||||
|
<string name="player_time_remaining_label">TIME REMAINING</string>
|
||||||
|
<string name="player_season_finale_label">SEASON FINALE</string>
|
||||||
|
<string name="player_season_finale_value">%1$s · Season %2$d finale</string>
|
||||||
|
<string name="player_finishes_in_label">FINISHES IN</string>
|
||||||
|
<string name="player_resume_time_left_label">TIME LEFT</string>
|
||||||
|
<string name="player_finishes_in_value">%1$s (%2$s)</string>
|
||||||
|
<plurals name="player_time_remaining_cue">
|
||||||
|
<item quantity="one">%1$d min</item>
|
||||||
|
<item quantity="other">%1$d mins</item>
|
||||||
|
</plurals>
|
||||||
<string name="next_up_label">NEXT UP</string>
|
<string name="next_up_label">NEXT UP</string>
|
||||||
<string name="next_up_play_now">Play now</string>
|
<string name="next_up_play_now">Play now</string>
|
||||||
<string name="next_up_dismiss">Dismiss</string>
|
<string name="next_up_dismiss">Dismiss</string>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.ponzischeme89.memby.data
|
package com.ponzischeme89.memby.data
|
||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
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.GatewayNextEpisode
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
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.GatewaySearchHistory
|
||||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
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 kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -19,6 +23,17 @@ import org.junit.Test
|
|||||||
* side, this fails before a TV ever sees it.
|
* side, this fails before a TV ever sees it.
|
||||||
*/
|
*/
|
||||||
class GatewayPayloadTest {
|
class GatewayPayloadTest {
|
||||||
|
@Test
|
||||||
|
fun `decodes signed in devices without an allowance`() {
|
||||||
|
val response = json.decodeFromString<GatewayDevices>(
|
||||||
|
"""{"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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -38,6 +53,40 @@ class GatewayPayloadTest {
|
|||||||
assertEquals(1, status.serverProtocol)
|
assertEquals(1, status.serverProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes live feature policy and recovery state`() {
|
||||||
|
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||||
|
"""{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
|
||||||
|
)
|
||||||
|
val policy = json.decodeFromString<GatewayFeatures>(
|
||||||
|
"""{"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<RecommendationOnboarding>(
|
||||||
|
"""{
|
||||||
|
"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
|
@Test
|
||||||
fun `decodes a home payload with emby-shaped items`() {
|
fun `decodes a home payload with emby-shaped items`() {
|
||||||
val payload = """
|
val payload = """
|
||||||
@@ -119,12 +168,12 @@ class GatewayPayloadTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `decodes the informational Sonarr schedule row`() {
|
fun `decodes the informational TV schedule row`() {
|
||||||
val payload = """
|
val payload = """
|
||||||
{
|
{
|
||||||
"rows": [{
|
"rows": [{
|
||||||
"id":"sonarr-airing-today",
|
"id":"sonarr-airing-today",
|
||||||
"title":"Shows airing today",
|
"title":"Shows airing in the next 5 days",
|
||||||
"kind":"schedule",
|
"kind":"schedule",
|
||||||
"items":[{
|
"items":[{
|
||||||
"Id":"sonarr:7:42",
|
"Id":"sonarr:7:42",
|
||||||
@@ -134,7 +183,8 @@ class GatewayPayloadTest {
|
|||||||
"MembySource":"sonarr",
|
"MembySource":"sonarr",
|
||||||
"MembyEpisodeTitle":"The Crossing",
|
"MembyEpisodeTitle":"The Crossing",
|
||||||
"MembyEpisodeCode":"S02E04",
|
"MembyEpisodeCode":"S02E04",
|
||||||
"MembyAirLabel":"Airs today at 8:00 PM",
|
"MembyAirDayLabel":"Tomorrow",
|
||||||
|
"MembyAirLabel":"Tomorrow: 8:00 PM",
|
||||||
"MembyAvailability":"downloading",
|
"MembyAvailability":"downloading",
|
||||||
"MembyAvailabilityText":"Downloading",
|
"MembyAvailabilityText":"Downloading",
|
||||||
"MembyPlayable":false
|
"MembyPlayable":false
|
||||||
@@ -150,12 +200,48 @@ class GatewayPayloadTest {
|
|||||||
|
|
||||||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||||||
|
|
||||||
assertTrue(item.isSonarrSchedule)
|
assertTrue(item.isTvSchedule)
|
||||||
assertEquals("S02E04", item.membyEpisodeCode)
|
assertEquals("S02E04", item.membyEpisodeCode)
|
||||||
|
assertEquals("Tomorrow", item.membyAirDayLabel)
|
||||||
assertEquals("Downloading", item.membyAvailabilityText)
|
assertEquals("Downloading", item.membyAvailabilityText)
|
||||||
assertEquals(false, item.membyPlayable)
|
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<GatewayHome>(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
|
@Test
|
||||||
fun `a home payload without rows still decodes`() {
|
fun `a home payload without rows still decodes`() {
|
||||||
// The gateway omits recommendation rows while they are still building, and an
|
// The gateway omits recommendation rows while they are still building, and an
|
||||||
@@ -186,13 +272,26 @@ class GatewayPayloadTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `decodes a playback response`() {
|
fun `decodes a playback response`() {
|
||||||
val playback = json.decodeFromString<GatewayPlayback>(
|
val playback = json.decodeFromString<GatewayPlayback>(
|
||||||
"""{"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("9", playback.itemId)
|
||||||
assertEquals(42_000L, playback.resumePositionMs)
|
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"))
|
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes a one-time auto-follow acknowledgement`() {
|
||||||
|
val response = json.decodeFromString<GatewayPlaybackReportResponse>(
|
||||||
|
"""{"autoFollowedShowTitle":"Severance"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Severance", response.autoFollowedShowTitle)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `decodes the next-episode response the player counts down to`() {
|
fun `decodes the next-episode response the player counts down to`() {
|
||||||
val next = json.decodeFromString<GatewayNextEpisode>(
|
val next = json.decodeFromString<GatewayNextEpisode>(
|
||||||
@@ -235,13 +334,4 @@ class GatewayPayloadTest {
|
|||||||
assertNull(next.item.episodeCode)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ class GatewayUpdateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `an optional verdict is dismissable`() {
|
fun `an optional verdict is dismissable`() {
|
||||||
val update = json.decodeFromString<GatewayUpdate>(
|
val update = json.decodeFromString<GatewayUpdate>(
|
||||||
"""{"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)
|
assertTrue(update.isOptional)
|
||||||
assertFalse(update.isMandatory)
|
assertFalse(update.isMandatory)
|
||||||
assertTrue(update.isActionable)
|
assertTrue(update.isActionable)
|
||||||
|
assertEquals("abc", update.sha256)
|
||||||
|
assertEquals(123L, update.sizeBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.ponzischeme89.memby.data
|
package com.ponzischeme89.memby.data
|
||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.DeviceProfile
|
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.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class PlaybackReportMathTest {
|
class PlaybackReportMathTest {
|
||||||
@@ -26,4 +30,45 @@ class PlaybackReportMathTest {
|
|||||||
assertEquals("Encode", profiles["pgssub"])
|
assertEquals("Encode", profiles["pgssub"])
|
||||||
assertEquals("Encode", profiles["dvdsub"])
|
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" } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,17 @@ class ProfileSettingsTest {
|
|||||||
assertEquals(30, family.forYouMinutes)
|
assertEquals(30, family.forYouMinutes)
|
||||||
assertEquals(false, family.hasOpenedForYou)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ class RowAnalyticsTest {
|
|||||||
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
|
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
|
@Test
|
||||||
fun `focusing a row that was never reported still records the impression`() {
|
fun `focusing a row that was never reported still records the impression`() {
|
||||||
val collector = analytics(FakeClock())
|
val collector = analytics(FakeClock())
|
||||||
|
|||||||
@@ -85,5 +85,28 @@ class ServiceAlertTest {
|
|||||||
assertEquals("sonarr:7:42:aired", alert.id)
|
assertEquals("sonarr:7:42:aired", alert.id)
|
||||||
assertEquals("sonarr:7:42", alert.itemId)
|
assertEquals("sonarr:7:42", alert.itemId)
|
||||||
assertEquals("sonarr", alert.imageTag)
|
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<GatewayServiceStatus>(
|
||||||
|
"""
|
||||||
|
{"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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Any>(code, "error".toResponseBody()))
|
||||||
|
}
|
||||||
@@ -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<Context>()
|
||||||
|
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<Context>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot
|
|||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class AiringTodayTagsTest {
|
class AiringTodayTagsTest {
|
||||||
@Test
|
@Test
|
||||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
fun `tags matching recommended series from today's TV schedule`() {
|
||||||
val home = HomeSnapshot(
|
val home = HomeSnapshot(
|
||||||
rows = listOf(
|
rows = listOf(
|
||||||
HomeRow(
|
HomeRow(
|
||||||
@@ -22,6 +23,7 @@ class AiringTodayTagsTest {
|
|||||||
name = "The Bear",
|
name = "The Bear",
|
||||||
type = "MembySonarrEpisode",
|
type = "MembySonarrEpisode",
|
||||||
membySource = "sonarr",
|
membySource = "sonarr",
|
||||||
|
membyAirDayLabel = "Today",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -56,6 +58,7 @@ class AiringTodayTagsTest {
|
|||||||
name = "Marvel's DAREDEVIL",
|
name = "Marvel's DAREDEVIL",
|
||||||
type = "MembySonarrEpisode",
|
type = "MembySonarrEpisode",
|
||||||
membySource = "sonarr",
|
membySource = "sonarr",
|
||||||
|
membyAirDayLabel = "Today",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -85,6 +88,7 @@ class AiringTodayTagsTest {
|
|||||||
name = "Fargo",
|
name = "Fargo",
|
||||||
type = "MembySonarrEpisode",
|
type = "MembySonarrEpisode",
|
||||||
membySource = "sonarr",
|
membySource = "sonarr",
|
||||||
|
membyAirDayLabel = "Today",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,4 +102,80 @@ class AiringTodayTagsTest {
|
|||||||
|
|
||||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
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(""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HomeHeroPick>) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<String>(), badgesAt(3_600))
|
||||||
|
assertEquals(listOf("4K"), badgesAt(3_840))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `returns no badges when stream metadata is unavailable`() {
|
fun `returns no badges when stream metadata is unavailable`() {
|
||||||
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
|
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui
|
|||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
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.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -55,9 +59,8 @@ class SeriesDetailsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `detail tabs default safely to episodes`() {
|
fun `season zero is labelled specials`() {
|
||||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
assertEquals("Specials", seasonLabel(0))
|
||||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
assertEquals("Season 3", seasonLabel(3))
|
||||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache
|
|||||||
import com.ponzischeme89.memby.data.Settings
|
import com.ponzischeme89.memby.data.Settings
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -15,6 +16,28 @@ import org.junit.Test
|
|||||||
* the server invented, and surviving a cold start from cache.
|
* the server invented, and surviving a cold start from cache.
|
||||||
*/
|
*/
|
||||||
class ServerHomeRowsTest {
|
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 }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@@ -73,10 +96,7 @@ class ServerHomeRowsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `successful login welcome uses authenticated username`() {
|
fun `successful login welcome uses authenticated username`() {
|
||||||
assertEquals(
|
assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. "))
|
||||||
"You're now logged in as Matt. Welcome to Memby!",
|
|
||||||
loginWelcomeMessage(" Matt "),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -189,7 +209,61 @@ class ServerHomeRowsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
||||||
|
|
||||||
val rows = serverHomeRows(
|
val rows = serverHomeRows(
|
||||||
@@ -199,7 +273,24 @@ class ServerHomeRowsTest {
|
|||||||
|
|
||||||
assertEquals(1, rows.size)
|
assertEquals(1, rows.size)
|
||||||
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
|
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
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
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.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
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
|
* 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.
|
* 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
|
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic
|
||||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
* cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it.
|
||||||
*
|
*
|
||||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
* [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
|
* 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",
|
id = "sonarr:7:42:aired",
|
||||||
title = "Northbound",
|
title = "Northbound",
|
||||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
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",
|
id = "sonarr:8:43:aired",
|
||||||
title = "The Long Dark",
|
title = "The Long Dark",
|
||||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
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 " +
|
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||||
"downloading now.",
|
"downloading now.",
|
||||||
posterUrl = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest {
|
|||||||
id = "sonarr:3:12:aired",
|
id = "sonarr:3:12:aired",
|
||||||
title = "Dune",
|
title = "Dune",
|
||||||
message = "S01E01 aired and will be in Emby soon.",
|
message = "S01E01 aired and will be in Emby soon.",
|
||||||
posterUrl = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest {
|
|||||||
id = "sonarr:7:42:aired",
|
id = "sonarr:7:42:aired",
|
||||||
title = "Northbound",
|
title = "Northbound",
|
||||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||||
posterUrl = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
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)
|
AlertBanner(alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user