18 Commits
Author SHA1 Message Date
ponzischeme89 598a4f5c75 Release v0.2.39
Publish Memby release / release (push) Canceled after 0s
2026-08-09 16:57:14 +12:00
ponzischeme89 52e167ea59 Release v0.2.38
Publish Memby release / release (push) Canceled after 0s
2026-08-09 16:04:40 +12:00
ponzischeme89 0dffd59440 Release v0.2.37 2026-08-09 13:10:55 +12:00
ponzischeme89 574826dc03 Release v0.2.36 2026-08-09 12:53:25 +12:00
ponzischeme89 add8aa831c Release v0.2.35 2026-08-09 09:36:35 +12:00
ponzischeme89 0c623bbcc0 Complete genre browsing for v0.2.34 2026-08-09 08:56:42 +12:00
ponzischeme89 fdd9e6cab2 Release v0.2.34 2026-08-09 08:25:50 +12:00
ponzischeme89 b1128bcce2 Release v0.2.33 2026-08-08 16:56:18 +12:00
ponzischeme89andClaude Opus 5 80c304d86b App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:44:17 +12:00
ponzischeme89andClaude Opus 5 4a4df7a73c App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:33:56 +12:00
ponzischeme89 2675e6d82b Add Memby account management 2026-08-03 10:16:44 +12:00
ponzischeme89 666da9c5d3 Add optional MDBList movie ratings 2026-08-03 08:55:52 +12:00
ponzischeme89 b6b2a9c25a Merge GitHub repository history 2026-08-02 22:11:04 +12:00
ponzischeme89 1ed180c739 Publish current app and server 2026-08-02 22:10:19 +12:00
Matt f12042ad24 Initial commit 2026-08-02 22:01:18 +12:00
ponzischeme89 a265636139 Big changes 2026-07-29 15:26:55 +12:00
ponzischeme89 70914400b4 Big changes 2026-07-29 15:26:27 +12:00
ponzischeme89 8d6cf2f5a1 Server changes/Sonarr 2026-07-27 21:06:51 +12:00
472 changed files with 83197 additions and 2532 deletions
+93 -9
View File
@@ -5,22 +5,46 @@ MEMBY_EMBY_URL=https://molise.bounceme.net
# What TVs are told to stream from. Only set this when it differs from the address # What TVs are told to stream from. Only set this when it differs from the address
# above — video goes device -> Emby directly, never through the gateway. # above — video goes device -> Emby directly, never through the gateway.
#MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net
# Postgres password for the memby role. Generate one, e.g. # Postgres password for the memby role. Generate one, e.g.
# openssl rand -base64 24 # openssl rand -base64 24
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649be2
# Host port the gateway listens on. # Fixed NAS host port used by the mserver.sublogue.com reverse proxy.
MEMBY_PORT=8080 MEMBY_PORT=32768
# INFO is recommended. DEBUG also logs successful health, status and artwork requests,
# playback progress reports and search terms.
MEMBY_LOG_LEVEL=INFO
MEMBY_LOG_BUFFER_CAPACITY=5000
# console (default) is one aligned line per event, timestamp first and no `time=` key.
# logfmt restores slog's own key=value line; json is for a log collector.
MEMBY_LOG_FORMAT=console
MEMBY_GOMEMLIMIT=384MiB
MEMBY_SERVER_MEMORY_LIMIT=512m
# Used for local Sonarr air times and Radarr digital-release day boundaries.
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=
# Admin interface at http://<host>:8080/admin/ — library imports, the maintenance # Maximum number of distinct Memby TVs one Emby user may keep signed in.
# switch, and row analytics. Leave blank to disable /admin entirely. Generate with
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with
# openssl rand -hex 32 # openssl rand -hex 32
MEMBY_ADMIN_TOKEN= MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd8c
# Public gateway address and a dedicated token used only by the Gitea release workflow.
# Generate the token with: openssl rand -hex 32
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
MEMBY_RELEASE_PUBLISH_TOKEN=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
@@ -28,5 +52,65 @@ MEMBY_SYNC_ON_START=false
# Optional Emby service account for imports. Without it the gateway borrows the most # Optional Emby service account for imports. Without it the gateway borrows the most
# recently active TV session, which works but stops if that user is removed. # recently active TV session, which works but stops if that user is removed.
#MEMBY_SYNC_USER_ID= MEMBY_SYNC_USER_ID=23bf90fc1fe049ce898db7adda1ed7bf
#MEMBY_SYNC_API_KEY= MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075
# Optional Sonarr calendar integration. Use the URL reachable from this container,
# not necessarily the address entered in a browser. The API key is in Sonarr under
# Settings > General > Security.
MEMBY_SONARR_URL=http://10.0.0.2:8989
MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a
MEMBY_SONARR_TTL=5m
# How long after an episode airs the "aired, coming soon" banner keeps being offered.
# 0 turns the banners off and leaves the airing-today row alone.
MEMBY_SONARR_ALERT_WINDOW=3h
# Optional Radarr calendar integration. Upcoming movies are selected strictly from
# Radarr's digital release date; theatrical and physical dates are ignored.
MEMBY_RADARR_URL=http://10.0.0.2:7878
MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5
MEMBY_RADARR_TTL=5m
# Shared secret for Radarr's "On Import" webhook, which announces a newly added film on
# every TV that is awake. In Radarr: Settings > Connect > + > Webhook, On Import only,
# URL https://<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
# Optional Bazarr integration, one of the two providers a viewer can fetch a missing
# subtitle from. Bazarr writes the file beside the media file, so Emby serves the result
# and Memby stores nothing — leaving these unset simply means Bazarr is never offered. The
# API key is in Bazarr under Settings > General.
#
# Whether viewers may actually use it is a switch on the admin console's Subtitles page,
# not an environment variable: the address is deployment configuration and belongs here,
# but turning the provider on and off is an operator's decision that should not need a
# redeployment. The second provider, OpenSubtitles, is configured entirely on that page —
# it needs only an API key, and the credentials live in the database rather than in this
# file.
MEMBY_BAZARR_URL=http://10.0.0.2:6767
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an
# Emby item into the Radarr/Sonarr id Bazarr keys on.
MEMBY_BAZARR_TTL=5m
# A manual search queries live subtitle providers and is legitimately slow, so it gets a
# longer timeout than the rest of the upstreams. Too short reads to a viewer as "no
# subtitles found" rather than as a timeout.
MEMBY_BAZARR_TIMEOUT=45s
# How often the gateway checks that Emby is answering. A run of failures raises the
# "server not responding" banner on every TV, including mid-playback, and recovery
# raises the matching "back online" one. 0 turns the probe and both banners off.
MEMBY_EMBY_HEALTH_INTERVAL=60s
# Optional Tracearr-powered For You signals. Create a read-only public API key in
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0
MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b
MEMBY_TRACEARR_SYNC_INTERVAL=5m
MEMBY_TRACEARR_FULL_INTERVAL=24h
MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h
MEMBY_FOR_YOU_REFRESH_INTERVAL=24h
MEMBY_FOR_YOU_REBUILD_HOUR=4
+72
View File
@@ -0,0 +1,72 @@
name: Publish Memby release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android 35 build tools
shell: bash
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
- name: Read version from tag
shell: bash
run: |
VERSION="${GITEA_REF_NAME#v}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag must look like v0.1.54" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITEA_ENV"
- name: Restore release keystore
shell: bash
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITEA_WORKSPACE/memby-release.jks"
- name: Test and build signed APK
shell: bash
env:
MEMBY_KEYSTORE: ${{ gitea.workspace }}/memby-release.jks
MEMBY_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
MEMBY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
MEMBY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew --console=plain testDebugUnitTest assembleRelease \
-Pmemby.versionName="$VERSION"
- name: Publish to Memby gateway
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.MEMBY_RELEASE_PUBLISH_TOKEN }}
run: |
NOTES="$(git log -1 --pretty=%B)"
curl --fail-with-body --show-error --silent \
-X POST \
-H "Authorization: Bearer $RELEASE_TOKEN" \
-F "version=$VERSION" \
-F "notes=$NOTES" \
-F "apk=@app/build/outputs/apk/release/app-release.apk;type=application/vnd.android.package-archive" \
https://mserver.sublogue.com/admin/api/release
+4
View File
@@ -18,9 +18,13 @@ 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.
/dist/out/ /dist/out/
*.jks *.jks
*.keystore *.keystore
# The installed APK a deploy script pulls off a television to compare against.
/.tmp-memby-installed-base.apk
+61
View File
@@ -0,0 +1,61 @@
# AGENTS.md
Guidance for coding agents working in this repository. **`CLAUDE.md` is the full brief** —
what Memby is, how it is built and deployed, and why the architecture is the shape it is.
Read it first. This file carries the rules that are easiest to break without noticing.
## Language: New Zealand English
**Everything a person reads is New Zealand English. No American spellings.**
- `-ise` / `-isation`, never `-ize` / `-ization` — personalise, synchronisation, organise,
recognise, prioritise.
- `-our`, never `-or` — colour, favourite, behaviour, honour, neighbour.
- `-re`, never `-er` — centre, theatre, metre.
- **licence** the noun, *license* the verb. **programme** for a broadcast, *program* for
software.
- grey, catalogue, cancelled, labelled, travelling, dialogue, defence, mould.
This covers on-screen copy in Kotlin and `app/src/main/res/values/strings.xml`,
`CHANGELOG.md` (the TV renders it twice — Settings → About, and the what's-new panel after
an update), the admin console at `server/internal/api/admin.html`, the release landing page
in `dist/template/`, every string the gateway sends the client to display — row titles,
alert `label`s, the preference and feature catalogues, error messages — and this
repository's own prose and comments.
### What is exempt, and why
**Identifiers and anything on a wire keep the spelling they already have.** Renaming them
breaks the wire or the build:
- Emby's API — `favorites`, `IsFavorite`, `SearchTerm`.
- Android and Compose — `Color`, `fontSize`, `TheaterComedy`, `RecognizerIntent`.
- Go and Kotlin — `synchronized`, and the literal `"request canceled"` in `images.go`,
which is matched against `net/http`'s own error text.
- SPDX and licence names — "GNU General Public License", `GPL-2.0-only`.
- CSS properties — `color`, `text-transform`, `box-sizing`.
The rule is about words a person reads, never tokens a machine matches.
### Where the two meet
The boundary sits at the render. The favourites row is the worked example: the id and the
row `kind` stay `favorites` on both sides of the wire, and `personalisedFavouritesTitle`
puts **"Favourites"** on the screen. When a new string is both a key and a label, spell the
display half and leave the key alone.
A quick sweep before finishing a change that touches copy:
```bash
grep -rnEio '\b([a-z]{3,}iz(e|es|ed|ing|ation)|behaviors?|colors?|favorites?|honor|theater|canceled|labeled|catalog|gray)\b' \
app/src/main server/internal dist CHANGELOG.md
```
Expect hits — most are the exemptions above. Read each one and ask whether a viewer or an
operator ever sees it.
## Version bumps
Every version bump is a release change: update the version and changelog together, then
commit and push the complete release to GitHub. Do not leave a bumped version only in the
local working tree.
+112
View File
@@ -0,0 +1,112 @@
## 0.2.39 — 2026-08-09
- Added: An optional confirmation before the Back button closes Memby.
- Improved: Reviewed D-pad navigation across the Android TV experience and documented follow-up work.
## 0.2.38 — 2026-08-09
- Improved: Ratings are larger and easier to read.
- Improved: Genre cards now use the same poster format as home-screen rows.
- Improved: The Memby rail logo now animates when opening every section.
- Improved: The user switcher now shows each profile's initials.
- Added: The About screen now shows the connected Emby gateway version.
- Changed: The genre browser is now controlled by the server and is off by default while its layout is refined.
- Fixed: The automatic home-screen artwork setting no longer reverts to posters.
- Fixed: Watched changes now appear on posters immediately, just like favourite changes.
## 0.2.37 — 2026-08-09
- Improved: Browse films and TV shows from colourful genre cards with clearer, consistent categories.
- Improved: Genre pages now keep their place and show Favourite changes immediately after returning from a title.
- Improved: App updates now show a brief confirmation instead of a full-screen release-notes panel.
- Fixed: Episodes without their own cast information now use the series cast in the player.
## 0.2.36 — 2026-08-09
- Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging.
## 0.2.35 — 2026-08-09
- General bug fixes and improvements.
## 0.2.34 — 2026-08-09
- General bug fixes and improvements.
## 0.2.33 — 2026-08-08
- General bug fixes and improvements.
## 0.2.32 — 2026-08-08
- General bug fixes and improvements.
- Backend improvements.
## 0.2.31 — 2026-08-08
- General bug fixes and improvements.
## 0.2.30 — 2026-08-08
- General bug fixes and improvements.
## 0.2.29 — 2026-08-07
- Fixed: A small bug with new seek previews.
- Fixed: Skip Intro and Outro Credits were not appearing in some cases.
## 0.2.28 — 2026-08-07
- Added: Skip outro credits
- Added: Themes for Halloween, Christmas and Easter.
## 0.2.27 — 2026-08-06
- Added: Skip Intro button
- Fixed: Player improvements & bug fixes
- Fixed: Gateway bug fixes & improvements
- Fixed: Homepage hero relevancy
- Fixed: Related titles showing up empty often
- Fixed: You can add another user to the television again, from Manage users
## 0.2.26 — 2026-08-05
- Improved: Ratings are more accurate and easier to read.
- Fixed: Continue Watching now keeps the right order as you watch.
- Fixed: Film and show pages no longer display incorrectly.
- Fixed: The loading screen no longer appears when you skip forward or back.
## 0.2.25 — 2026-08-05
- Added: Left and right on the remote skip back and forward while you watch.
- Added: You can choose whether one press skips 10, 20 or 30 seconds.
- Added: You can search for and add subtitles without leaving what you are watching.
- Added: A cast button in the player shows who is in the scene.
- Added: Episode lists now show when each episode aired.
- Improved: Ratings now show the IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb logos.
## 0.2.24 — 2026-08-05
- Fixed: The centre button on the remote pauses straight away instead of taking two presses.
- Fixed: Reinstalling Memby on a television replaces it in your devices list instead of adding another one.
- Fixed: Removing a television from Devices now clears it from Emby as well.
- Improved: The subtitle menu is quicker to read and easier to move around.
## 0.2.23 — 2026-08-04
- Added: Turning subtitles off, and the language you pick, are saved to your account.
- Added: Tags showing whether a show is continuing, has ended, or is still in cinemas.
- Improved: Settings is simpler, darker and easier to read.
- Fixed: The Play button on the Home screen is no longer squashed by long titles.
## 0.2.22 — 2026-08-04
- Fixed: Updates no longer fail with a warning about an untrusted release key.
- Fixed: Updates now install on televisions where they previously did nothing.
- Added: Memby asks for permission to install updates while you are setting up.
- Added: Instructions for televisions that have no install permission screen.
- Improved: You are now told clearly when an update fails.
## 0.2.20 — 2026-08-04
- Added: A warning appears when Memby cannot reach your Emby server.
- Added: Your settings are saved to your account and restored on any television you sign into.
- Added: Title logos, auto-play and the ten-minute reminder are saved for each person.
## 0.2.17 — 2026-08-03
- Added: Episodes in Continue Watching now open their own page.
- Improved: Resume buttons show which episode you are up to.
## 0.2.16 — 2026-08-03
- Added: You can rename or sign out televisions from Settings → Devices.
- Added: Optional ratings on film pages.
- Added: A version history in About, so you can see what changed.
- Improved: The navigation rail stays visible while you are in Settings.
## 0.2.0 — 2026-05-01
- Added: Instant search with an on-screen keyboard.
- Added: Alerts for newly added titles, library updates and server outages.
- Added: Personalised "Why you might enjoy it" suggestions on film and show pages.
- Improved: Film and series pages now share one layout.
- Improved: Home screen rows can change without updating the app.
+1799 -19
View File
File diff suppressed because it is too large Load Diff
+216
View File
@@ -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 (250420dp) 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.
+340
View File
@@ -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.
+48
View File
@@ -0,0 +1,48 @@
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, server-side
device-profile generation, and ExoPlayer construction (constant-bitrate
seeking and sourcing media over the application's own OkHttp client, in
app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt), and the
design of its seek preview thumbnails
(app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt), are
adapted from Wholphin, an Android TV client licensed under GNU GPL version 2:
https://github.com/damontecres/Wholphin
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.
The marks shown beside each score in the ratings strip
(app/src/main/res/drawable-nodpi/ic_rating_*.png) identify the rating provider
a score came from. IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb are
trademarks of their respective owners, used here only to attribute those
scores. Memby is not affiliated with, endorsed by, or certified by any of them.
+96 -28
View File
@@ -1,26 +1,70 @@
# Memby (Android TV) # Memby (Android TV)
An independent Android TV client for Emby, by **ponzischeme89**. Sign in to browse your An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a
personalised home screen, resume titles, play recent movies, and choose audio or subtitle personalised television launcher, full media player, system screensaver and an optional
tracks during playback. The screensaver (Dream) code remains isolated from the client work. self-hosted gateway that shapes the experience for every viewer in the household.
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 ### Browse and discover
TV's Screensaver settings. Also previewable from the app's home screen.
- Random, cross-fading **backdrops** of Movies and Series that have backdrop images. - Personalised, server-composed Home rows with a cached instant-start launcher.
- **Interactive**: OK reveals Play / Favorite actions; ◄ ► change the backdrop; Back exits. - A cinematic featured hero that rotates through ranked films and series premieres.
- **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its - Continue Watching merged with Next Up, ordered by the viewer's recent activity.
next-up / first episode). - Favourites, latest films, recommendations, curated shelves and schedule rows.
- **Favorites** managed through the Emby API and shown on the home screen. - Dedicated Movies, TV Shows, Favourites and For You destinations.
- Instant search with an on-screen keyboard, voice input and session result caching.
- Optional, server-controlled genre browsing with paged film and series catalogues.
- A dedicated For You page combining viewing history, browsing signals, available time
and direct-play evidence; every recommendation explains why it was selected.
- More Like This, cast and technical details on film and series pages.
- Per-title IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb ratings where available.
- My Shows tracking, upcoming episodes, lifecycle badges and personal return alerts.
- Themes, seasonal colour schemes and optional launcher decorations.
### Playback
- Media3/ExoPlayer playback with direct play, compatible-stream remuxing and HLS fallback.
- Resume playback, progress reporting, watched state and immediate poster badge updates.
- Audio and subtitle track selection with per-viewer language preferences.
- Missing-subtitle search and download through Bazarr or OpenSubtitles when configured.
- Configurable Left/Right seeking with trickplay thumbnail previews.
- Skip-intro prompts or automatic skipping from Emby's chapter markers.
- Automatic next-episode playback with an on-screen countdown.
- Closing-credits presentation with the next episode alongside and adaptive 2× playback.
- Episode pages with season navigation, watched markers and estimated completion dates.
- In-player cast lookup, pause artwork, time-remaining reminders and service-status banners.
### Profiles, televisions and administration
- Multiple saved viewer profiles with quick switching from the navigation rail.
- Per-viewer settings synchronised through the gateway across every signed-in television.
- Device management, session revocation and stable television identity.
- Gateway-controlled feature flags, maintenance mode, forced app updates and live themes.
- Service alerts for new library items, aired episodes, deployments and Emby outages.
- A dependency-free web console for accounts, devices, settings history, features,
updates, library imports, recommendations, searches, row engagement and logs.
- Postgres catalogue import, Redis caching, full-text search and background recommendation
and ratings warming.
### Screensaver and updates
- A system `DreamService` screensaver with cross-fading library backdrops.
- Interactive screensaver controls for playback, favourites and artwork navigation.
- An in-app screensaver preview using the same rendering surface as the system Dream.
- Self-hosted signed APK updates with SHA-256, package and signing-key verification.
- Mandatory and optional update policy controlled by the gateway.
## Tech stack ## Tech stack
- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material`) - Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`)
- **Media3 / ExoPlayer** for playback - **Media3 / ExoPlayer** for playback
- Retrofit + OkHttp + kotlinx.serialization for the Emby REST API - Retrofit + OkHttp + kotlinx.serialization for the Emby REST API
- DataStore for persisted connection/session - DataStore for persisted profiles, settings and cached launcher state
- Coil for backdrop image loading - Coil for backdrop image loading
- Go gateway with Postgres and Redis
## Project layout ## Project layout
@@ -31,7 +75,7 @@ app/src/main/java/com/ponzischeme89/memby/
data/ data/
SettingsStore.kt DataStore-backed connection/session state SettingsStore.kt DataStore-backed connection/session state
ServerConfig.kt Which backend this build talks to ServerConfig.kt Which backend this build talks to
EmbyRepository.kt Content, favorites, playback; dual gateway/direct paths EmbyRepository.kt Content, favourites, playback; dual gateway/direct paths
analytics/RowAnalytics.kt Row engagement buffering analytics/RowAnalytics.kt Row engagement buffering
model/ Emby + gateway DTOs (kotlinx.serialization) model/ Emby + gateway DTOs (kotlinx.serialization)
remote/EmbyApi.kt Retrofit interface for Emby remote/EmbyApi.kt Retrofit interface for Emby
@@ -81,7 +125,7 @@ and the TV holds a revocable gateway token rather than a live Emby token.
``` ```
direct: TV ──────────────────────────────► Emby direct: TV ──────────────────────────────► Emby
gateway: TV ──► Memby gateway ──► Emby (metadata + artwork) gateway: TV ──► Memby gateway ──► Emby (metadata + artwork)
TV ─────────────────────► Emby (video, always direct-play) TV ─────────────────────► Emby (video stream; never proxied by the gateway)
``` ```
Which one a build uses is decided by `memby.gatewayUrl` in `gradle.properties`: set it and Which one a build uses is decided by `memby.gatewayUrl` in `gradle.properties`: set it and
@@ -93,6 +137,15 @@ new episodes), composes the home rows — including "Recommended from your watch
history" — and has an admin page at `/admin/` for imports, an offline switch, and history" — and has an admin page at `/admin/` for imports, an offline switch, and
per-row engagement. per-row engagement.
For Tracearr-powered recommendations, create a read-only public API key in Tracearr and
set `MEMBY_TRACEARR_URL` plus `MEMBY_TRACEARR_API_KEY` on the gateway (and optionally
`MEMBY_TRACEARR_SERVER_ID` when Tracearr monitors several servers). Tracearr credentials
remain server-side; the Android app only receives ranked Emby items and short reasons.
The gateway imports compact session signals into Postgres every five minutes and prepares
an over-provisioned per-user pool, so opening For You normally performs one indexed
database read. The live on-demand path remains available for cold starts and failed
rebuilds.
## Server address ## Server address
Memby is built for one Emby server, so the address is baked into the APK instead of being Memby is built for one Emby server, so the address is baked into the APK instead of being
@@ -151,7 +204,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
@@ -161,6 +216,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`
@@ -168,13 +225,11 @@ decides what the app offers, so rolling back is editing one file.
### How TVs update themselves ### How TVs update themselves
In Memby's Settings, set the update URL to `https://nas.example.com/memby/latest.json`. The gateway checks the running app version on launch and returns an optional or mandatory
**Check for updates** then downloads and installs on the TV, no computer involved. A URL update with its download address. Memby downloads the APK, verifies its checksum, package
ending in `.json` is read as a static manifest; anything else is treated as a Gitea host and release signature, then commits it through Android's package installer. Update policy
(`/api/v1/repos/{owner}/{repo}/releases/latest`), so either source works. and the download address are managed in the gateway's admin console; viewers do not need
to configure an update source on the television.
The manifest's `apkUrl` may be relative (`memby-0.1.54.apk`) and is resolved against the
manifest's own URL, so the folder keeps working if the NAS is reached by another name.
First install on each TV still has to be manual — the **Downloader** app pointed at the First install on each TV still has to be manual — the **Downloader** app pointed at the
landing page is the usual route, and the page explains it. landing page is the usual route, and the page explains it.
@@ -193,12 +248,25 @@ 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.
+97 -6
View File
@@ -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,6 +16,42 @@ 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") + "\""
// The About page's version history. Kept as one checked-in document rather than a Kotlin
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
val changelogText = rootProject.file("CHANGELOG.md").readText()
val gplLicenseText = rootProject.file("LICENSE").readText()
val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.39"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
?.takeIf { it.matches(Regex("""\d+\.\d+\.\d+""")) }
?: defaultVersionName
val membyVersionParts = membyVersionName.split('.').map(String::toInt)
val membyVersionCode =
membyVersionParts[0] * 10_000 + membyVersionParts[1] * 100 + membyVersionParts[2]
/** Reads a key from local.properties, which is gitignored and holds machine secrets. */ /** Reads a key from local.properties, which is gitignored and holds machine secrets. */
val localProperties = Properties().apply { val localProperties = Properties().apply {
val file = rootProject.file("local.properties") val file = rootProject.file("local.properties")
@@ -37,14 +74,17 @@ android {
applicationId = "com.ponzischeme89.memby" applicationId = "com.ponzischeme89.memby"
minSdk = 23 minSdk = 23
targetSdk = 35 targetSdk = 35
// versionCode is derived from versionName: major*10000 + minor*100 + patch. // Derived from one version string so CI cannot publish a versionName/versionCode
// 0.1.53 -> 153. Keep them in step; the in-app updater compares versionName, // pair that Android later refuses to install.
// but Android will not install an APK whose versionCode went backwards. versionCode = membyVersionCode
versionCode = 153 versionName = membyVersionName
versionName = "0.1.53"
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))
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
} }
// Release signing. Android identifies an app by (applicationId, signing key), so // Release signing. Android identifies an app by (applicationId, signing key), so
@@ -68,7 +108,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"
@@ -105,6 +151,21 @@ android {
abortOnError = false abortOnError = false
} }
testOptions {
unitTests {
// Robolectric needs the merged resources to inflate anything; only the
// screenshot tests use them.
isIncludeAndroidResources = true
// Roborazzi writes PNGs only in record mode. These images are artifacts to
// look at, not checked-in goldens to diff against, so recording is always on
// — a screenshot test that silently captures nothing is worse than none.
all {
it.systemProperty("roborazzi.test.record", "true")
}
}
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -122,9 +183,19 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
// ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top,
// instead of hitting the gateway every ten seconds for as long as the process lives.
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
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")
// Names the playback launch phases in a systrace so :benchmark can measure them.
// Free when tracing is off, which is every build a viewer ever runs.
implementation("androidx.tracing:tracing-ktx:1.2.0")
// Compose (versions from BOM) // Compose (versions from BOM)
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
@@ -152,8 +223,28 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1") implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1") implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1") implementation("androidx.media3:media3-ui:1.5.1")
// Lets the player pull its bytes through the app's one OkHttp stack instead of
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
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
// with no Android on the classpath — see the note in CLAUDE.md. Rendering a
// composable is the one thing that genuinely cannot be done that way, and these are
// confined to *ScreenshotTest.kt files.
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.2")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.2")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
} }
+43 -5
View File
@@ -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
+56 -17
View File
@@ -6,6 +6,40 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). --> <!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets a self-update apply without a confirmation screen once Memby is its own
installer of record (Android 12+). Ignored before that, and never required: the
install session falls back to asking. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
<queries>
<!-- EmbyAppLauncher hands an item to an installed Emby client. -->
<package android:name="com.mb.android" />
<package android:name="tv.emby.embyatv" />
<!-- The per-app "install unknown apps" screen. Many TV builds do not implement it;
declaring it here is what lets the updater tell the difference between "the
viewer has not granted this yet" and "this TV has nowhere to grant it". -->
<intent>
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
</intent>
<!-- openScreensaverSettings probes the TV's own settings screens, best first. -->
<intent>
<action android:name="android.settings.DREAM_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.DISPLAY_SETTINGS" />
</intent>
<intent>
<action android:name="android.settings.SETTINGS" />
</intent>
<!-- Search's microphone button. Without this, SpeechRecognizer.isRecognitionAvailable
reports false on Android 11+ even where a recogniser exists, and the button
would be hidden on devices that support voice perfectly well. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. --> <!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
<uses-feature <uses-feature
@@ -19,18 +53,22 @@
android:name=".MembyApp" android:name=".MembyApp"
android:allowBackup="true" android:allowBackup="true"
android:banner="@drawable/app_banner" android:banner="@drawable/app_banner"
android:icon="@drawable/app_banner" android:icon="@drawable/emby_logo"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/emby_logo"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby"> android:theme="@style/Theme.Memby">
<profileable android:shell="true" tools:targetApi="q" /> <profileable android:shell="true" tools:targetApi="q" />
<!-- Home / setup screen. Registered on the TV (Leanback) launcher. --> <!-- Home / setup screen. Registered on the TV (Leanback) launcher.
Televisions are fixed landscape, so the DiscouragedApi advice about adapting to
other orientations does not apply to any device that can install this app. -->
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="landscape" android:screenOrientation="landscape"
tools:ignore="DiscouragedApi"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode"> android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -43,10 +81,11 @@
android:name=".ui.screensaver.ScreensaverActivity" android:name=".ui.screensaver.ScreensaverActivity"
android:exported="false" android:exported="false"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen" /> android:theme="@style/Theme.Memby.Fullscreen"
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">
@@ -62,14 +101,15 @@
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation" android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen" /> android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- The system screensaver (Daydream / Ambient mode source). <!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. --> Interactive: select to open the panel, play, or favourite. -->
<service <service
android:name=".screensaver.MembyDreamService" android:name=".screensaver.MembyDreamService"
android:exported="true" android:exported="true"
android:icon="@drawable/app_banner" android:icon="@drawable/emby_logo"
android:label="@string/screensaver_name" android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE"> android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter> <intent-filter>
@@ -81,16 +121,15 @@
android:resource="@xml/emby_dream" /> android:resource="@xml/emby_dream" />
</service> </service>
<!-- Serves the downloaded update APK to the system package installer. --> <!-- Outcome of a self-update's install session. Not exported: the only sender is
<provider the system, through a PendingIntent this app created. -->
android:name="androidx.core.content.FileProvider" <receiver
android:authorities="${applicationId}.fileprovider" android:name=".update.InstallResultReceiver"
android:exported="false" android:exported="false">
android:grantUriPermissions="true"> <intent-filter>
<meta-data <action android:name="com.ponzischeme89.memby.INSTALL_RESULT" />
android:name="android.support.FILE_PROVIDER_PATHS" </intent-filter>
android:resource="@xml/file_paths" /> </receiver>
</provider>
</application> </application>
</manifest> </manifest>
@@ -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()
} }
@@ -1,8 +1,12 @@
package com.ponzischeme89.memby package com.ponzischeme89.memby
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
/** /**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the * Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -10,14 +14,45 @@ import com.ponzischeme89.memby.data.SettingsStore
* settings instance without pulling in a DI framework. * settings instance without pulling in a DI framework.
*/ */
object ServiceLocator { object ServiceLocator {
// [init] only ever stores an applicationContext, whose lifetime is the process, so this
// holds nothing that could outlive its owner. Keep it that way.
@SuppressLint("StaticFieldLeak")
lateinit var settings: SettingsStore lateinit var settings: SettingsStore
private set private set
lateinit var repository: EmbyRepository lateinit var repository: EmbyRepository
private set private set
lateinit var maintenance: MaintenanceMonitor
private set
/**
* Held rather than discarded because it is a long-lived collector, not a service
* anything calls: it starts working when it is constructed and must not be collected
* while a viewer's settings are half-synced.
*/
lateinit var preferencesSync: PreferencesSync
private set
/**
* Held for the same reason [preferencesSync] is, and read as well as held: the settings
* picker asks it which schemes this viewer may choose and whether a season has taken the
* choice away for the moment.
*/
lateinit var themeSync: ThemeSync
private set
fun init(context: Context) { fun init(context: Context) {
if (::repository.isInitialized) return if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext) settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings) repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the
// app already asks the gateway a question every ten seconds, and settings do not
// deserve a second connection.
preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision)
// Likewise rides the status poll. It is constructed here rather than by a screen
// because the palette has to be applied before the first frame of the launcher, and
// because the surfaces that obey it — the launcher, the player's Compose islands,
// the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme)
} }
} }
@@ -0,0 +1,24 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
/**
* Loads the cast Emby associates with an item.
*
* Episodes commonly omit `People`, while their parent series holds the canonical cast.
* Keep episode-specific credits when Emby supplies them, and only pay for the series
* request when the episode has no cast of its own.
*/
internal suspend fun resolveCast(
itemId: String,
loadItem: suspend (String) -> BaseItem,
): List<EmbyPerson> {
val item = loadItem(itemId)
if (item.cast.isNotEmpty()) return item.cast
val seriesId = item.seriesId
?.takeIf(String::isNotBlank)
?.takeUnless { it == item.id }
return if (item.isEpisode && seriesId != null) loadItem(seriesId).cast else emptyList()
}
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* The direct-to-Emby copy of the gateway's `mergeContinueWatching`.
*
* The rule exists twice on purpose, like the subtitle rule does: with no gateway there is
* nobody to ask, and Continue Watching must not hold different cards depending on whether
* the container is up. `ContinueWatchingTest` and `continue_watching_test.go` are
* deliberately parallel — change one and change the other.
*
* Resume items and Next Up episodes are one row because they answer one question. Both
* lists arrive most-recently-watched first, so this is a merge of two sorted lists and
* never a sort of their union: Emby's order within each is the useful part. What places a
* Next Up episode is when its *series* was last watched, since the episode itself is
* unwatched and carries no date of its own.
*/
internal fun mergeContinueWatching(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
seriesLastPlayed: Map<String, String>,
): List<BaseItem> {
val seenItems = mutableSetOf<String>()
val seenSeries = mutableSetOf<String>()
val inProgress = resume.map { item ->
seenItems += item.id
item.continueSeriesKey()?.let { seenSeries += it }
item to item.continuePlayedAt(seriesLastPlayed)
}
// A series being watched right now is represented by the episode it is part-way
// through, not by the one after it.
val upNext = nextUp
.filter { item ->
val series = item.continueSeriesKey()
item.id !in seenItems && (series == null || seenSeries.add(series))
}
.map { item -> item to item.continuePlayedAt(seriesLastPlayed) }
val merged = ArrayList<BaseItem>(inProgress.size + upNext.size)
var left = 0
var right = 0
while (left < inProgress.size && right < upNext.size) {
val (nextItem, nextAt) = upNext[right]
val (currentItem, currentAt) = inProgress[left]
// An undated card never displaces a dated one, and falls back to the resume half:
// that is the list which is definitely in progress.
if (nextAt != null && (currentAt == null || nextAt > currentAt)) {
merged += nextItem
right++
} else {
merged += currentItem
left++
}
}
while (left < inProgress.size) merged += inProgress[left++].first
while (right < upNext.size) merged += upNext[right++].first
return merged
}
private fun BaseItem.continueSeriesKey(): String? = seriesId?.takeIf(String::isNotBlank)
private fun BaseItem.continuePlayedAt(seriesLastPlayed: Map<String, String>): String? =
normalizePlayedAt(userData?.lastPlayedDate)
?: continueSeriesKey()?.let { normalizePlayedAt(seriesLastPlayed[it]) }
/**
* Emby writes UTC timestamps with .NET's seven fractional digits
* (`2026-08-06T21:04:05.1234567Z`), so trimming to whole seconds makes them fixed-width
* and directly comparable as strings — `java.time` is not available at this minSdk.
* Anything that is not a recognisable timestamp is undated rather than mis-ordered.
*/
internal fun normalizePlayedAt(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.length < 19) return null
val stamp = trimmed.take(19)
val shaped = stamp[4] == '-' && stamp[7] == '-' && stamp[10] == 'T' &&
stamp[13] == ':' && stamp[16] == ':'
if (!shaped) return null
val digits = stamp.filterIndexed { index, _ -> index !in setOf(4, 7, 10, 13, 16) }
if (!digits.all(Char::isDigit)) return null
// Emby's "never played" sentinel. A real date it is not, and treating it as one would
// sort a card by a year nobody watched anything in.
if (stamp.startsWith("0001-01-01")) return null
return stamp
}
@@ -0,0 +1,148 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
/**
* Where a title's closing credits begin, and whether they are worth doing anything about.
*
* Two sources, in order of trust, because Emby gives one and the media gives the other:
*
* - `CreditsStart`, a marker Emby's own detector writes. It is in Emby's `MarkerType`
* enumeration and is what this feature was originally built on — but **Emby 4.10 does not
* write it**. A survey of a 20,000-item library found `Chapter`, `IntroStart` and `IntroEnd`
* and nothing else, so the enum value existing is not the detector populating it. It is
* still read first, so the day a version does write it this needs no change.
* - A chapter *named* like credits. Plenty of media carries "Credits" or "End Credits" as
* ordinary chapter metadata, and in that same library 216 items had one, clustered at 9098%
* of runtime and consistent within a show. That is where the coverage comes from today.
*
* Both come out of the same `Fields=Chapters` response [introSegmentFrom] already reads, so
* this costs no request the intro was not already making.
*
* There is deliberately no end marker in either source. Credits run to the end of the file by
* definition, so nothing writes one and this must not invent one.
*/
private const val MARKER_CREDITS_START = "CreditsStart"
/**
* How far into a file a credit roll has to begin.
*
* **This is the load-bearing guard, and it exists because of one observed case.** Chapter names
* are not a vocabulary anybody agreed on, and real media carries "Opening Credits" — Belfast at
* 1% of runtime, Game of Thrones at 0%. A name match without a position test therefore starts
* the credits pane in the *first minute* of a film and runs its opening at double speed, which
* is the worst thing this feature could possibly do.
*
* Three quarters is deliberately far below the evidence rather than near it: every genuine
* credit roll in that survey began at 90% or later, so this leaves fifteen points of headroom
* for a long roll while rejecting the whole first half of a file outright.
*/
private const val CREDITS_MINIMUM_POSITION_FRACTION = 0.75
/** Words that mark an *opening* sequence, never a closing one. */
private val OPENING_CHAPTER_WORDS =
listOf("opening", "main title", "title sequence", "intro")
/** Words that name a credit roll. */
private val CREDITS_CHAPTER_WORDS = listOf("credit", "end titles", "closing")
/**
* The least amount of credits worth shrinking the picture for.
*
* A marker twenty seconds from the end is not a credit roll to sit beside something else;
* it is the last card of one, and the transition would be most of what was left. This is
* the guard the gateway deliberately does not apply — it knows where the marker is but not
* how long the file runs, and the duration is exact here.
*/
const val CREDITS_MINIMUM_TAIL_MS = 45_000L
/**
* Finds where the closing credits begin in an item's chapter list.
*
* The rule exists twice — the gateway's copy is `creditsFromChapters` in
* `server/internal/api/credits.go` — and the two are pinned by deliberately parallel tests
* (`CreditsTest`, `credits_test.go`). With no gateway there is nobody to ask, and the
* picture must not start shrinking at a different moment depending on whether the container
* is up.
*
* Like the intro rule, most of this is about refusing to answer, and null is a perfectly
* good answer: the player never shrinks anything and the credits play out full size, which
* is what every other client does anyway.
*
* [runtimeMs] may be zero when Emby reports no runtime. An explicit marker is still honoured
* then — it is Emby asserting a position rather than this inferring one — but a *named* chapter
* is refused outright, because the name alone cannot tell an opening credit sequence from a
* closing one and [CREDITS_MINIMUM_POSITION_FRACTION] is the only thing that can.
*/
fun creditsStartFrom(chapters: List<EmbyChapter>, runtimeMs: Long): Long? {
val floorMs =
if (runtimeMs > 0L) (runtimeMs * CREDITS_MINIMUM_POSITION_FRACTION).toLong() else -1L
// An explicit marker first. **The last one wins, where [introSegmentFrom] takes the
// first.** That inversion is deliberate: two starts mean the markers are already
// untrustworthy, so each rule picks whichever risks least, and the two features are damaged
// in opposite directions. An intro skip that fires late throws somebody past the start of
// the story, so the earlier marker is safer there; the credits pane firing early runs the
// last scene of an episode past somebody at double speed, so the later marker is safer
// here. Neither is a preference for a position in the list.
var markedMs = -1L
for (chapter in chapters) {
if (chapter.markerType != MARKER_CREDITS_START) continue
// A marker at or before zero says the whole file is credits, which is not something
// Emby means and not something worth acting on.
if (chapter.startPositionTicks <= 0L) continue
markedMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
}
// A marker below the floor is a mis-detection whoever wrote it, so it falls through to the
// names rather than being honoured — but with no runtime to measure against, an explicit
// assertion gets the benefit of the doubt.
if (markedMs > 0L && (floorMs < 0L || markedMs >= floorMs)) return markedMs
if (floorMs < 0L) return null
// Then the names. The *earliest* qualifying chapter wins here, the opposite of the marker
// rule above, and that is not an inconsistency: several credits-named chapters are ordinary
// rather than suspicious — "The Pitt" carries both "Credits" and "End Credits" — and they
// describe one roll, which begins at the first of them.
var namedMs = -1L
for (chapter in chapters) {
if (!isCreditsChapterName(chapter.name) || chapter.startPositionTicks <= 0L) continue
val at = chapter.startPositionTicks / TICKS_PER_MILLISECOND
if (at < floorMs) continue
if (namedMs < 0L || at < namedMs) namedMs = at
}
return namedMs.takeIf { it > 0L }
}
/**
* Recognises a chapter that names a credit roll.
*
* The exclusions are belt-and-braces beside [CREDITS_MINIMUM_POSITION_FRACTION], which is what
* actually stops an opening sequence being read as a closing one — a position test catches
* wordings nobody thought of, where a list of them only catches the ones on the list. They are
* here so the trap is stated where the next reader will look for it.
*/
private fun isCreditsChapterName(name: String): Boolean {
val lowered = name.trim().lowercase()
if (lowered.isEmpty()) return false
if (OPENING_CHAPTER_WORDS.any { it in lowered }) return false
return CREDITS_CHAPTER_WORDS.any { it in lowered }
}
/**
* Whether a credits marker is worth acting on in a file of this length.
*
* Separate from finding the marker, and deliberately on this end of the wire: the gateway
* knows where `CreditsStart` sits but not how long the file runs, while the player has the
* exact duration the decoder reported. Both guards refuse rather than guess — a marker past
* the end of the file, or one so near it that the transition would outlast the credits, is
* a detection to ignore rather than a picture to shrink.
*/
fun creditsWorthShowing(startMs: Long?, durationMs: Long): Boolean {
if (startMs == null || startMs <= 0L || durationMs <= 0L) return false
if (startMs >= durationMs) return false
return durationMs - startMs >= CREDITS_MINIMUM_TAIL_MS
}
private const val TICKS_PER_MILLISECOND = 10_000L
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* Browsing a genre: what came back, where it started, and how much there is.
*
* Kept apart from the search results it shares a pane with, because the two answer
* different questions. A search is one response and it is either everything or nothing;
* a genre is a shelf somebody scrolls, so what matters about a page is where it sits in
* the whole and whether there is another one behind it.
*/
data class GenrePage(
val items: List<BaseItem>,
val offset: Int,
/**
* How many titles the genre holds. Zero from a backend that would not count, which
* [hasMoreGenreItems] reads as "this is all there is" rather than as an invitation to
* keep asking — a grid that asks forever is worse than one that stops early, since the
* viewer can always search.
*/
val total: Int,
)
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
const val GENRE_PAGE_SIZE = 48
/**
* Whether the grid should ask for another page.
*
* Two things end a scroll and both have to, because either one alone leaves a real case
* broken. Reaching the total is the ordinary end. A page that came back *short* of what was
* asked for is the other: a backend that did not count says nothing useful with its total,
* and without this the grid would go on asking for pages of a genre that ran out.
*/
fun hasMoreGenreItems(loaded: Int, total: Int, lastPageSize: Int, pageSize: Int): Boolean {
if (loaded == 0) return false
if (lastPageSize < pageSize) return false
return loaded < total
}
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.data
import android.content.Context
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What Memby is holding of the library's artwork on this television.
*
* Two caches, deliberately reported apart: the disk half is the 128MB of posters and
* backdrops under `cacheDir/media_artwork` and survives a restart, while the memory half is
* decoded bitmaps and is gone the moment Android reclaims the process. Adding them into one
* figure would tell a viewer that emptying the cache frees more storage than it does.
*/
data class ImageCacheSize(
val diskBytes: Long,
val memoryBytes: Long,
) {
val totalBytes: Long get() = diskBytes + memoryBytes
companion object {
val EMPTY = ImageCacheSize(0L, 0L)
}
}
/**
* A size a viewer reads on a television across the room, so it is one number and a unit —
* never a byte count with six digits in it.
*
* Units are binary (a kilobyte is 1024 bytes), because that is what Coil's own budget is
* measured in and a figure that disagreed with the cache's stated maximum would look wrong.
* Below a megabyte nothing is worth a decimal point; above it one place is enough to show
* the number moving.
*/
fun formatCacheSize(bytes: Long): String {
if (bytes <= 0L) return "0 MB"
val kb = 1024.0
val mb = kb * 1024.0
val gb = mb * 1024.0
return when {
bytes < kb -> "$bytes B"
bytes < mb -> "${Math.round(bytes / kb)} KB"
bytes < gb -> "${roundToOneDecimal(bytes / mb)} MB"
else -> "${roundToOneDecimal(bytes / gb)} GB"
}
}
private fun roundToOneDecimal(value: Double): String {
val tenths = Math.round(value * 10.0)
val whole = tenths / 10
val remainder = tenths % 10
return if (remainder == 0L) whole.toString() else "$whole.$remainder"
}
/**
* Measuring and emptying Coil's caches.
*
* Both sides are off the main thread: reading the disk cache's size walks its journal, and
* clearing it deletes up to 128MB of files — either one on the main thread is a settings
* screen that stops answering the remote.
*/
@OptIn(ExperimentalCoilApi::class)
object ImageCacheMaintenance {
suspend fun measure(context: Context): ImageCacheSize = withContext(Dispatchers.IO) {
val loader = context.applicationContext.imageLoader
ImageCacheSize(
diskBytes = runCatching { loader.diskCache?.size ?: 0L }.getOrDefault(0L),
memoryBytes = runCatching { loader.memoryCache?.size?.toLong() ?: 0L }.getOrDefault(0L),
)
}
/**
* Empties both halves and answers with what is left, which on a working set is nothing.
* Memory goes first: a bitmap still held there would be re-written to disk by the very
* next card that asked for it, and the figure reported back would not be zero.
*/
suspend fun clear(context: Context): ImageCacheSize = withContext(Dispatchers.IO) {
val loader = context.applicationContext.imageLoader
runCatching { loader.memoryCache?.clear() }
runCatching { loader.diskCache?.clear() }
measure(context)
}
}
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
/**
* Where an episode's opening titles sit, in milliseconds from the start of the file.
*
* Emby finds these itself and writes them into the item's chapter list as two markers,
* `IntroStart` and `IntroEnd`, interleaved with the ordinary chapters in playback order.
* So there is nothing to detect on this end and nothing to store: the answer is already in
* the library, and reading it is one field on a request that was going to be made anyway.
*/
data class IntroSegment(val startMs: Long, val endMs: Long) {
/**
* Whether the playhead is inside the titles, with [lead] of the end held back.
*
* The lead is what stops the button appearing for the last half-second of a sequence,
* where pressing it would be indistinguishable from doing nothing — and, in automatic
* mode, what stops a seek to a point the film has already reached.
*/
fun contains(positionMs: Long, lead: Long = 0L): Boolean =
positionMs >= startMs && positionMs < endMs - lead
}
/**
* The shortest span worth calling an intro. Emby occasionally writes a pair a couple of
* seconds apart on a title whose opening it half-recognised, and a button that skips two
* seconds is worse than no button: somebody presses it, the picture does not visibly move,
* and the feature reads as broken.
*/
private const val INTRO_MINIMUM_MS = 5_000L
/**
* The longest. A pair minutes apart is a mis-detection — a recap, a cold open, or two
* unrelated markers read as a range — and honouring it would throw a viewer past the start
* of the story.
*/
private const val INTRO_MAXIMUM_MS = 5 * 60 * 1_000L
private const val MARKER_INTRO_START = "IntroStart"
private const val MARKER_INTRO_END = "IntroEnd"
/**
* Finds the title sequence in an item's chapter list.
*
* The rule exists twice — the gateway's copy is `introFromChapters` in
* `server/internal/api/intro.go` — and the two are pinned by deliberately parallel tests
* (`IntroTest`, `intro_test.go`). With no gateway there is nobody to ask, and a skip must
* not land somewhere different depending on whether the container is up.
*
* Most of this is about refusing to answer. A pair that is out of order, too short, too
* long, or missing half of itself produces null, and null is a perfectly good answer: the
* player simply never offers the button. A wrong skip costs somebody the opening of a
* scene, which is far worse than not being offered one.
*/
fun introSegmentFrom(chapters: List<EmbyChapter>): IntroSegment? {
var startMs = -1L
for (chapter in chapters) {
when (chapter.markerType) {
MARKER_INTRO_START ->
// The first start wins, and a second is ignored rather than replacing it.
// Two starts mean the markers are already untrustworthy; taking the later
// one would pick the larger, more damaging skip of the two.
if (startMs < 0L && chapter.startPositionTicks >= 0L) {
startMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
}
MARKER_INTRO_END -> {
// An end before any start is a stray marker, not the close of a segment.
if (startMs < 0L) continue
val endMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
val length = endMs - startMs
if (length < INTRO_MINIMUM_MS || length > INTRO_MAXIMUM_MS) return null
return IntroSegment(startMs = startMs, endMs = endMs)
}
}
}
return null
}
private const val TICKS_PER_MILLISECOND = 10_000L
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.data
/**
* Local-day arithmetic, shared by everything that has to answer "what day is it here?".
*
* It lived in `HomeMovieHero` while the daily hero rotation was the only caller; the series
* pace estimate counts in local days for the same reason and must not carry a second copy.
*/
internal const val DAY_MS = 24L * 60L * 60L * 1000L
/**
* The day the television is living in, counted in local days since the epoch.
*
* Local rather than UTC, because "resets at midnight" means the viewer's midnight and not
* Greenwich's. The zone offset is a parameter rather than something read in here so the
* arithmetic stays pure and a test can put itself either side of the date line.
*/
internal fun localEpochDay(nowMs: Long, zoneOffsetMs: Int): Long =
floorDiv(nowMs + zoneOffsetMs, DAY_MS)
/**
* How long until the local day rolls over. Never zero and never negative: this drives a
* timer, and a zero would spin it. Exactly at midnight the answer is a whole day, because
* the day that has just begun is not the one being waited for.
*/
internal fun millisUntilNextLocalDay(nowMs: Long, zoneOffsetMs: Int): Long {
val local = nowMs + zoneOffsetMs
return DAY_MS - (local - floorDiv(local, DAY_MS) * DAY_MS)
}
// Math.floorDiv/floorMod for longs arrived in API 24 and this app still ships to 23, so
// the two lines they would have saved are written out instead.
internal fun floorDiv(value: Long, divisor: Long): Long {
val quotient = value / divisor
return if (value % divisor != 0L && (value xor divisor) < 0L) quotient - 1L else quotient
}
internal fun floorMod(value: Long, divisor: Long): Long =
value - floorDiv(value, divisor) * divisor
@@ -0,0 +1,413 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayAlert
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)
/**
* Emby is not answering, and something is still trying.
*
* Distinct from [MaintenanceNotice], which is Memby being taken down deliberately: this is
* the media server behind it going quiet. It matters to the viewer for one specific
* reason — video direct-plays from Emby, so during an outage a film stops with no
* explanation at all, while Memby itself stays up and can say why.
*
* [nextAttemptAtMillis] is elapsed-realtime, not wall clock, so the countdown cannot be
* thrown by a TV correcting its clock over the network mid-outage.
*/
data class EmbyOutage(
val nextAttemptAtMillis: Long,
val retryIntervalSeconds: Int,
)
/**
* One informational banner: a show aired, a film was added, the library finished
* refreshing, the server stopped answering. It is never actionable and never focusable —
* it slides in, says its piece and goes, over the launcher or over playback alike.
*
* [label] is the eyebrow above the title and comes from the server, so a kind of news
* this build has never heard of still reads correctly; a server that sends none gets the
* original wording back.
*/
data class ServiceAlert(
val id: String,
val title: String,
val message: String,
val label: String = "",
)
/**
* One process-wide control channel shared by every Memby activity.
*
* A network failure leaves the last confirmed state intact: losing connectivity while a
* maintenance notice is showing must not briefly reopen playback. A successful status
* response is the only thing that enters or clears maintenance.
*
* The same poll carries alerts, so news reaches an open app without a second connection.
* The gateway keeps offering an alert for as long as it is current and has no idea which
* TVs have seen it, so *this* side owns "shown already" — persisted, or every relaunch
* would replay yesterday's news.
*
* The loop runs only while a Memby screen is in the foreground, and an alert counts as
* shown only when the banner says so ([alertShown]). Both exist for the same reason: work
* done for a screen nobody is looking at is worse than wasted, because it also burns the
* one chance to deliver the news.
*/
class MaintenanceMonitor(
private val repository: EmbyRepository,
private val settings: SettingsStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _notice = MutableStateFlow<MaintenanceNotice?>(null)
val notice: StateFlow<MaintenanceNotice?> = _notice.asStateFlow()
private val _compatibility = MutableStateFlow<CompatibilityNotice?>(null)
val compatibility: StateFlow<CompatibilityNotice?> = _compatibility.asStateFlow()
private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
/** Null whenever Emby is answering, or when nothing is watching it. */
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _installPermissionPrompt = MutableStateFlow(false)
private val _genreBrowserEnabled = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
* revision. [ThemeSync] fetches the palette only when the revision moves.
*
* It rides this poll rather than the sign-in because that is the whole feature: a
* seasonal theme has to reach a set that is already switched on, at the midnight it
* begins, with nobody doing anything.
*/
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
* notices and fetches the document. Carrying the revision rather than the settings
* themselves keeps a poll that runs every ten seconds on every open TV to one integer.
*/
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
/**
* Whether the operator wants TVs that cannot install their own updates to be asked for
* the permission. Pushed through the same poll as everything else, and false whenever
* the server has not said otherwise — a screen that appears because a field was missing
* would be the wrong way round.
*/
val installPermissionPrompt: StateFlow<Boolean> = _installPermissionPrompt.asStateFlow()
/** Server-controlled because the browser layout is still being refined. */
val genreBrowserEnabled: StateFlow<Boolean> = _genreBrowserEnabled.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
private var seenAlertsLoaded = false
private var shownAlertId: String? = null
private var alertTimer: Job? = null
init {
scope.launchStatusLoop()
scope.launchDirectEmbyProbe()
}
/**
* Called by the banner once it is actually on screen. Until this arrives the alert is
* only *offered*: nothing is persisted and no timer runs, so an alert that lands while
* the screensaver is up or another app is in front survives to be shown later rather
* than being consumed by nobody.
*
* Seen is recorded on display rather than on dismissal, because a banner interrupted
* by a crash or a power cut is not worth replaying a day later.
*/
fun alertShown(id: String) {
if (shownAlertId == id) return
shownAlertId = id
seenAlertIds += id
scope.launch { runCatching { settings.markAlertSeen(id) } }
alertTimer?.cancel()
alertTimer = scope.launch {
delay(ALERT_VISIBLE_MS)
if (_alert.value?.id == id) _alert.value = null
}
}
/** Hides the current banner early — the viewer has already read it. */
fun dismissAlert() {
alertTimer?.cancel()
shownAlertId = null
_alert.value = null
}
private fun CoroutineScope.launchStatusLoop() = launch {
// Only while a Memby screen is in front. Backgrounded, this loop is cancelled
// outright rather than left ticking a request every ten seconds at a TV nobody
// is looking at; coming back to the foreground restarts it with an immediate
// poll, so maintenance is still caught the moment the viewer returns.
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.settingsFlow.collectLatest { session ->
if (!ServerConfig.isGateway || !session.isSignedIn) {
_notice.value = null
_compatibility.value = null
// The direct-path probe owns the outage bar when there is no gateway;
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
}
while (isActive) {
runCatching { repository.serviceStatus() }
.onSuccess { status ->
_compatibility.value = if (status.compatible) {
null
} else {
CompatibilityNotice(
status.compatibilityMessage.trim().ifEmpty {
"This Memby app and server are not compatible. Update the app or contact the server administrator."
},
)
}
_notice.value = if (status.maintenance) {
MaintenanceNotice(
status.message.trim().ifEmpty {
"Memby is down for maintenance. Try again shortly."
},
)
} else {
null
}
_preferencesRevision.value = status.preferencesRevision
_theme.value = status.theme
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
_gatewayVersion.value = status.gatewayVersion
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby)
if (status.maintenance) {
// The maintenance screen owns the display; anything
// cheerful in front of it would only be confusing.
dismissAlert()
} else {
offerNextAlert(status.alerts)
}
}
.onFailure { error ->
if (isUnauthorizedError(error)) {
repository.invalidateSession()
_notice.value = null
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
}
}
delay(POLL_INTERVAL_MS)
}
}
}
}
/**
* Turns the gateway's reading into a countdown this TV can render.
*
* The next attempt is computed from *this device's* elapsed clock rather than from the
* server's timestamps, because the two are not the same clock and the difference would
* show up as a countdown that jumps. `checkedAt` is deliberately unused for that
* reason: it says when the probe ran on the gateway, which is only a lower bound on
* when this poll heard about it.
*/
private fun outageFrom(health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth): EmbyOutage? =
nextOutageState(health, _embyOutage.value, elapsedRealtime())
/**
* The direct path's own probe. With no gateway there is nobody to ask, so the app asks
* Emby itself — on the same minute the gateway would have used, so the bar reads
* identically either way.
*
* It runs only while signed in and only on the direct path, and only in the
* foreground: a background loop hitting a server every minute for a screen nobody is
* looking at is exactly what the status poll already refuses to do.
*/
private fun CoroutineScope.launchDirectEmbyProbe() = launch {
if (ServerConfig.isGateway) return@launch
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.settingsFlow.collectLatest { session ->
if (!session.isSignedIn) {
_embyOutage.value = null
return@collectLatest
}
var failures = 0
while (isActive) {
if (repository.pingEmby()) {
failures = 0
_embyOutage.value = null
} else {
failures++
// Same threshold the gateway applies, and for the same reason:
// one failed request is a hiccup, and a red bar for it is worse
// than a moment of silence. Once past it the countdown is re-armed
// on every attempt, because the next attempt really is a minute
// from now.
if (failures >= DIRECT_OUTAGE_THRESHOLD) {
_embyOutage.value = EmbyOutage(
nextAttemptAtMillis =
elapsedRealtime() + DEFAULT_RETRY_SECONDS * 1000L,
retryIntervalSeconds = DEFAULT_RETRY_SECONDS,
)
}
}
delay(DEFAULT_RETRY_SECONDS * 1000L)
}
}
}
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
if (!seenAlertsLoaded) {
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
seenAlertsLoaded = true
}
val pending = _alert.value
if (pending != null) {
if (pendingAlertExpired(pending.id, shownAlertId, alerts)) {
_alert.value = null
}
// One at a time either way; a queued second alert is still unseen next poll.
return
}
val next = firstUnseenAlert(alerts, seenAlertIds) ?: return
_alert.value = ServiceAlert(
id = next.id,
title = next.title.trim(),
message = next.message.trim(),
// 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(),
)
}
companion object {
internal const val POLL_INTERVAL_MS = 10_000L
/** Matches `featureInstallPermission` in the gateway's feature catalogue. */
internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
internal const val GENRE_BROWSER_FEATURE = "genre_browser"
/**
* How often Emby is retried during an outage. It matches the gateway's own
* default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the
* same thing whether the probe is running here or there.
*/
internal const val DEFAULT_RETRY_SECONDS = 60
/** Consecutive direct-path failures before the bar appears. */
internal const val DIRECT_OUTAGE_THRESHOLD = 2
/**
* How long one banner stays on screen. The banner draws a ring counting this
* down, so the two must agree — take the duration from here rather than
* hard-coding it in the UI.
*/
const val ALERT_VISIBLE_MS = 10_000L
}
}
/**
* The outage state to publish after a status poll, given the one already showing.
*
* The rule worth keeping is that an outage already on screen keeps its countdown until it
* actually runs out. The status poll is six times faster than the retry it is describing,
* so recomputing the deadline every time would reset the number every ten seconds and the
* viewer would watch a counter that never reaches zero.
*
* Pure, and takes [nowMillis] as a parameter, so the two properties that matter — a live
* countdown survives a poll, an expired one is re-armed — can be tested without waiting a
* minute for each.
*/
internal fun nextOutageState(
health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth,
existing: EmbyOutage?,
nowMillis: Long,
): EmbyOutage? {
if (!health.isOutage) return null
if (existing != null && existing.nextAttemptAtMillis > nowMillis) return existing
val retrySeconds = health.retrySeconds.takeIf { it > 0 }
?: MaintenanceMonitor.DEFAULT_RETRY_SECONDS
return EmbyOutage(
nextAttemptAtMillis = nowMillis + retrySeconds * 1000L,
retryIntervalSeconds = retrySeconds,
)
}
/**
* Whether an alert held for a screen that never appeared should be given up on.
*
* An offered alert waits indefinitely for a foreground banner, so something has to end
* that wait: once the gateway stops listing it, the episode aired long enough ago that
* announcing it is no longer news. An alert already shown is left alone — its own
* dismissal timer owns it.
*/
internal fun pendingAlertExpired(
pendingId: String,
shownId: String?,
alerts: List<GatewayAlert>,
): Boolean = pendingId != shownId && alerts.none { it.id == pendingId }
/**
* The first alert this TV has not shown before. Alerts arrive newest-first, and anything
* missing an id, a title or a message is dropped rather than rendered as an empty card.
*/
internal fun firstUnseenAlert(alerts: List<GatewayAlert>, seen: Set<String>): GatewayAlert? =
alerts.firstOrNull { alert ->
alert.id.isNotBlank() &&
alert.id !in seen &&
alert.title.isNotBlank() &&
alert.message.isNotBlank()
}
@@ -0,0 +1,164 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Keeps this television's settings and the viewer's server-held document the same thing.
*
* Three events move settings, and all three go through here:
*
* - **The viewer changes something on this TV.** The local write already happened —
* nothing in the UI waits on the network — and the new document is pushed up after it.
* - **The viewer changes something on another TV, or an operator pushes from the admin
* console.** The revision on the status poll goes up, this notices and pulls.
* - **A profile signs in somewhere for the first time.** The server's copy wins if it has
* one; otherwise this TV's settings become the starting document.
*
* The failure mode being designed against is a *loop*: a pull that looks like a local
* change and triggers a push, which bumps the revision, which looks like a remote change.
* [lastSynced] is what breaks it — it records the exact document both ends agreed on, and
* a push happens only when the local one differs from it. Adopting a pull sets it, so the
* write that follows never looks like news.
*
* Everything here fails silently and retries on the next event. Settings are not worth an
* error message on a television, and the state they are in — local values intact, server
* copy intact — is a correct one to be left in until the next poll.
*/
class PreferencesSync(
private val repository: EmbyRepository,
private val settings: SettingsStore,
private val remoteRevision: StateFlow<Long>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
/**
* The document last known to be identical on both ends, per profile. A local state
* matching this is not a change and must not be pushed.
*/
private var lastSynced: Pair<String, UserPreferences>? = null
/** One sync at a time: a pull and a push racing would decide the revision twice. */
private val mutex = Mutex()
init {
scope.launch { run() }
}
private suspend fun run() {
// Nothing to sync for a TV nobody is looking at, and the status poll this rides
// beside stops in the background too, so the revision would go stale anyway.
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
repository.settingsFlow,
remoteRevision,
) { session, revision -> session to revision }
// The launcher rewrites the home cache constantly and it is part of
// Settings; without this, every refresh would look like a settings change
// and be considered for a push.
.map { (session, revision) -> SyncTrigger(session, revision) }
.distinctUntilChanged()
// Plain collect, not collectLatest: a reconcile that is cancelled halfway
// could leave [lastSynced] describing a document that was never written.
// These are short and serialised by the mutex, so waiting is cheaper than
// reasoning about a half-applied sync.
.collect(::reconcile)
}
}
/** The only parts of the session a sync depends on. */
private data class SyncTrigger(
val profileKey: String,
val signedIn: Boolean,
val localRevision: Long,
val remoteRevision: Long,
val local: UserPreferences,
) {
constructor(session: Settings, remoteRevision: Long) : this(
profileKey = "${session.userId.orEmpty()}@${session.serverUrl.orEmpty()}",
signedIn = session.isSignedIn,
localRevision = session.preferencesRevision,
remoteRevision = remoteRevision,
local = session.toUserPreferences(),
)
}
private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway || !trigger.signedIn) return
mutex.withLock {
val agreed = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
// Never synced on this TV, or the server has moved on without us. Pull first:
// the server's copy is the shared truth, and a push here would overwrite a
// change made on another television with this one's defaults.
if (agreed == null || trigger.remoteRevision > trigger.localRevision) {
if (pull(trigger)) return
}
// Only a genuine local edit gets pushed. Anything else is either the document
// just adopted or an unrelated part of Settings changing.
val current = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
if (current != null && current != trigger.local) push(trigger)
}
}
/**
* Takes the server's copy. Returns true when the local document was replaced, so the
* caller knows not to treat the write it just made as a local edit.
*/
private suspend fun pull(trigger: SyncTrigger): Boolean {
val remote = runCatching { repository.userPreferences() }.getOrNull() ?: return false
// Revision 0 means the server has never been told anything about this viewer. The
// settings already on this television are then the best starting point there is,
// so they are pushed up rather than replaced by catalogue defaults.
if (remote.revision == 0L) {
lastSynced = trigger.profileKey to UserPreferences()
push(trigger)
return true
}
val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
// Written even when the values match, because the revision must advance: without
// it the next poll sees the same gap and pulls again, every ten seconds, forever.
runCatching { settings.applyRemotePreferences(decoded, remote.revision) }
.onFailure { lastSynced = null }
return true
}
private suspend fun push(trigger: SyncTrigger) {
val stored = runCatching {
repository.saveUserPreferences(trigger.localRevision, trigger.local.encode())
}.getOrNull() ?: return
// A 409 comes back here as an ordinary result carrying somebody else's document —
// an operator's push, nearly always. Adopting it is the correct outcome: this TV
// was editing a revision that no longer exists, and retrying would revert them.
val decoded = decodeUserPreferences(stored.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
if (decoded == trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
// The request may have been in flight while the viewer made a newer choice.
// A conflict describes the document that won against the request, not against
// an edit made after it. Advance that newer edit to the winning revision and
// let the queued trigger push it, instead of replacing it with stale values.
val current = runCatching { settings.snapshot().toUserPreferences() }.getOrNull()
if (current != null && current != trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
runCatching { settings.applyRemotePreferences(decoded, stored.revision) }
.onFailure { lastSynced = null }
}
}
}
}
@@ -0,0 +1,21 @@
package com.ponzischeme89.memby.data
/**
* How far one press of Left or Right moves the film.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it — the store, the settings row and the player — and the
* gateway's own catalogue (`seekIntervalSeconds` in `internal/api/preferences.go`) holds
* the matching list. Keep the two in step: a value this build does not recognise is
* normalised to the default rather than honoured, so an operator pushing an interval a
* television has never heard of costs that set the default and never an unexplained skip.
*/
/** The intervals a viewer may choose between, in the order the settings row offers them. */
val SEEK_INTERVAL_SECONDS: List<Int> = listOf(10, 20, 30)
/** Ten seconds is the smallest offered, and the one a mis-press costs least to undo. */
const val DEFAULT_SEEK_INTERVAL_SECONDS: Int = 10
fun normalizeSeekIntervalSeconds(seconds: Int): Int =
if (seconds in SEEK_INTERVAL_SECONDS) seconds else DEFAULT_SEEK_INTERVAL_SECONDS
@@ -0,0 +1,258 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import kotlin.math.ceil
/**
* When a viewer is likely to finish the episodes of a series they already have.
*
* Everything here is derived from the episode list the detail page already holds — Emby's
* `UserData.Played` and `UserData.LastPlayedDate`, one per episode — so there is no new
* storage, nothing to invalidate, and no second implementation on the direct path. It
* recalculates for free: finishing an episode, marking one watched, a history sync from
* another Emby client and a newly imported episode all change that list and nothing else.
* Because the state is Emby's per-user data, it is per viewer and per series by
* construction, including for two people sharing one television.
*
* It is deliberately apart from the UI. [estimateSeriesPace] answers with numbers and
* [seriesPaceLabel] turns them into a sentence, so a "finish this weekend" row or a
* completion reminder can use the first without inheriting the second's wording.
*
* The honest answer is often *nothing*. Every guard below returns null rather than a
* confident-looking date: a viewer told they will finish on a day they will not is worse
* off than one told nothing at all.
*/
data class SeriesPaceEstimate(
/** Unwatched episodes the household actually holds. Never fewer than two. */
val remainingEpisodes: Int,
/** The measured rate over the recent window, in episodes per local day. */
val episodesPerDay: Double,
/** Local days from today until the finish. 0 is today, 1 tomorrow. */
val daysAway: Int,
/** The finish, in local days since the epoch — the form the calendar wording needs. */
val finishEpochDay: Long,
/**
* Whether the show is still being made. It changes the verb, because "finish" is a
* claim about a series and only "catch up" is true of one that is still running.
*/
val catchUp: Boolean,
)
// The most recent completions worth measuring. Old enough behaviour is not this viewer's
// current pace — somebody who took a year over season one and is now watching nightly is
// watching nightly.
private const val PACE_WINDOW_EPISODES = 10
private const val PACE_WINDOW_DAYS = 30L
/**
* A gap this long ends the window. Somebody returning to a show after a fortnight away is
* establishing a new pace, and averaging the silence in would predict a finish years out.
*/
private const val PACE_BREAK_DAYS = 14L
/** Nothing watched in this long and there is no current pace to project at all. */
private const val PACE_STALE_DAYS = 30L
private const val PACE_MIN_EPISODES = 3
/**
* Two completions are enough only when they are on separate days *and* close enough
* together to read as a rhythm rather than as two unrelated evenings.
*/
private const val PACE_PAIR_MAX_SPAN_DAYS = 7L
/** Beyond a year the arithmetic is still valid and the answer is still useless. */
private const val PACE_MAX_HORIZON_DAYS = 365L
/**
* The estimate, or null when there is nothing trustworthy to say.
*
* [episodes] is the whole series as the library holds it — which is what makes the answer
* "the episodes available to you", since Emby lists nothing it has not imported. [ongoing]
* says the show is still in production; see [SeriesPaceEstimate.catchUp].
*/
fun estimateSeriesPace(
episodes: List<BaseItem>,
nowMs: Long,
zoneOffsetMs: Int,
ongoing: Boolean = false,
): SeriesPaceEstimate? {
if (episodes.isEmpty()) return null
// Specials are bonus material, not the thing being caught up on: counting them would
// put a finish date beyond the last real episode for a viewer who is never going to
// watch the making-of. The same rule `nextEpisodeToWatch` follows — unless specials
// are all the library has, in which case they are the show.
val numbered = episodes.filter { (it.parentIndexNumber ?: 0) > 0 }
val considered = if (numbered.isNotEmpty()) numbered else episodes
// A part-watched episode is not a completion: it counts as remaining and contributes
// nothing to the pace, which is exactly what "has not crossed the threshold" means.
val remaining = considered.count { it.userData?.played != true }
// One episode left needs no date, and none left needs no estimate.
if (remaining <= 1) return null
val completions = considered
.filter { it.userData?.played == true }
.mapNotNull { playedAtMillis(it.userData?.lastPlayedDate) }
.sorted()
if (completions.isEmpty()) return null
val mostRecent = completions.last()
// A stamp in the future is a clock that has not been set, not a viewing habit.
if (mostRecent - nowMs > DAY_MS) return null
if (nowMs - mostRecent > PACE_STALE_DAYS * DAY_MS) return null
val recent = completions
.takeLast(PACE_WINDOW_EPISODES)
.filter { mostRecent - it <= PACE_WINDOW_DAYS * DAY_MS }
val window = sinceLastBreak(recent)
val days = window.map { localEpochDay(it, zoneOffsetMs) }
// Everything known happening on one day is one sitting, and a sitting has no daily
// rate: three episodes on a Sunday would read as three a day and promise a finish
// this week. This is the guard that stops the opening of a binge projecting one.
val distinctDays = days.distinct().size
if (distinctDays < 2) return null
val spanDays = days.last() - days.first() + 1
if (window.size < PACE_MIN_EPISODES && spanDays > PACE_PAIR_MAX_SPAN_DAYS) return null
// Episodes per day over the whole span, inclusive of both ends — the count of viewing
// days is what a rate is measured against, so a binge of three on Saturday and three
// on Sunday is three a day rather than six.
val perDay = window.size.toDouble() / spanDays.toDouble()
if (perDay <= 0.0) return null
val daysNeeded = ceil(remaining / perDay).toLong()
if (daysNeeded > PACE_MAX_HORIZON_DAYS) return null
// The first of those days is today: at one a day with three left, the third is two
// days from now, not three.
val daysAway = (daysNeeded - 1L).coerceAtLeast(0L)
return SeriesPaceEstimate(
remainingEpisodes = remaining,
episodesPerDay = perDay,
daysAway = daysAway.toInt(),
finishEpochDay = localEpochDay(nowMs, zoneOffsetMs) + daysAway,
catchUp = ongoing,
)
}
/**
* The trailing run of [completions] with no [PACE_BREAK_DAYS] gap in it.
*
* Walking back from the most recent rather than forward from the oldest is the point: what
* is wanted is the pace the viewer is on *now*, so the window ends at the present and stops
* at whatever break precedes it.
*/
private fun sinceLastBreak(completions: List<Long>): List<Long> {
if (completions.size <= 1) return completions
var start = completions.size - 1
while (start > 0 && completions[start] - completions[start - 1] < PACE_BREAK_DAYS * DAY_MS) {
start--
}
return completions.subList(start, completions.size)
}
/**
* The sentence the detail page shows, or null for an absent estimate.
*
* Near dates are named, because "18 August" is something a viewer can hold a weekend up
* against. Far ones are rounded to weeks or months instead: a pace measured over a
* fortnight cannot honestly pick a day four months out, and printing one anyway is false
* precision dressed as helpfulness.
*/
fun seriesPaceLabel(estimate: SeriesPaceEstimate?): String? {
if (estimate == null) return null
val verb = if (estimate.catchUp) "catch up" else "finish"
return when {
estimate.daysAway == 0 -> "You'll likely $verb today"
estimate.daysAway == 1 -> "You'll likely $verb tomorrow"
estimate.daysAway <= 30 -> {
val date = formatPaceDate(estimate.finishEpochDay)
if (estimate.catchUp) "You'll catch up around $date" else "Estimated finish: $date"
}
else -> {
val tail = if (estimate.catchUp) "to catch up" else "remaining"
"At your current pace: about ${roughDuration(estimate.daysAway)} $tail"
}
}
}
/** "6 weeks", "3 months" — the coarse end of the wording, never a day count. */
private fun roughDuration(days: Int): String {
val months = (days + 15) / 30
if (days >= 70 && months >= 2) return "$months months"
val weeks = (days + 3) / 7
return if (weeks == 1) "1 week" else "$weeks weeks"
}
/**
* "18 August". Day first and no ordinal suffix, which is how a date is written here, and
* with the month named from this table rather than from the platform's locale — a set
* configured in US English must not start printing "August 18" into New Zealand copy.
*
* No year: this is only reached inside a month, where the next 18 August is unambiguous.
*/
internal fun formatPaceDate(epochDay: Long): String {
val (_, month, day) = civilFromEpochDay(epochDay)
return "$day ${MONTH_NAMES[month - 1]}"
}
private val MONTH_NAMES = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
/**
* Year, month and day from a count of days since 1970-01-01, by the usual civil-calendar
* algorithm. Hand-rolled because `java.time` needs API 26 and this app ships to 23, and
* because a `Calendar` would drag the device's default zone into a pure function that has
* already been handed the offset it needs.
*/
private fun civilFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
// Shift the epoch to 0000-03-01 so leap days land at the end of the cycle.
val shifted = epochDay + 719_468L
val era = floorDiv(shifted, 146_097L)
val dayOfEra = shifted - era * 146_097L
val yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36_524L - dayOfEra / 146_096L) / 365L
val year = yearOfEra + era * 400L
val dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L)
val monthPrime = (5L * dayOfYear + 2L) / 153L
val day = (dayOfYear - (153L * monthPrime + 2L) / 5L + 1L).toInt()
val month = (if (monthPrime < 10L) monthPrime + 3L else monthPrime - 9L).toInt()
return Triple((if (month <= 2) year + 1L else year).toInt(), month, day)
}
/**
* Emby's UTC timestamp as epoch milliseconds, or null for anything unrecognisable.
*
* [normalizePlayedAt] already rejects the malformed and the "never played" sentinel and
* hands back a fixed-width `yyyy-MM-ddTHH:mm:ss`; all that is left is to read the digits.
* Doing it by hand rather than with `SimpleDateFormat` keeps the function pure and free of
* that class's thread-safety problem, which the analytics buffer has to work around.
*/
internal fun playedAtMillis(value: String?): Long? {
val stamp = normalizePlayedAt(value) ?: return null
val year = stamp.substring(0, 4).toIntOrNull() ?: return null
val month = stamp.substring(5, 7).toIntOrNull() ?: return null
val day = stamp.substring(8, 10).toIntOrNull() ?: return null
val hour = stamp.substring(11, 13).toIntOrNull() ?: return null
val minute = stamp.substring(14, 16).toIntOrNull() ?: return null
val second = stamp.substring(17, 19).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return epochDayFromCivil(year, month, day) * DAY_MS +
(hour * 3_600L + minute * 60L + second) * 1_000L
}
/** The inverse of [civilFromEpochDay], by the same algorithm. */
private fun epochDayFromCivil(year: Int, month: Int, day: Int): Long {
val y = (if (month <= 2) year - 1 else year).toLong()
val era = floorDiv(y, 400L)
val yearOfEra = y - era * 400L
val monthPrime = if (month > 2) month - 3 else month + 9
val dayOfYear = (153L * monthPrime + 2L) / 5L + day - 1L
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
return era * 146_097L + dayOfEra - 719_468L
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
package com.ponzischeme89.memby.data
/**
* What happens when an episode reaches its opening titles.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it — the store, the settings row and the player — and the
* gateway's own catalogue (`skipIntroMode` in `internal/api/preferences.go`) holds the
* matching list. Keep the two in step: a value this build does not recognise is normalised
* to the default rather than honoured, so an operator pushing a mode a television has
* never heard of costs that set a button it already understood, never an unexplained jump
* through somebody's episode.
*
* [SKIP_INTRO_PROMPT] is the default deliberately. Skipping automatically is a jump in a
* film nobody asked for, and it must be chosen rather than arrived at.
*/
/** Offer a button. Nothing moves until somebody presses it. */
const val SKIP_INTRO_PROMPT = "prompt"
/** Jump past the titles as soon as playback reaches them, once per episode. */
const val SKIP_INTRO_AUTO = "auto"
/** Leave the titles alone. */
const val SKIP_INTRO_OFF = "off"
/** The modes a viewer may choose between, in the order the settings row offers them. */
val SKIP_INTRO_MODES: List<String> = listOf(SKIP_INTRO_PROMPT, SKIP_INTRO_AUTO, SKIP_INTRO_OFF)
const val DEFAULT_SKIP_INTRO_MODE: String = SKIP_INTRO_PROMPT
fun normalizeSkipIntroMode(mode: String?): String =
mode?.trim()?.lowercase()?.takeIf { it in SKIP_INTRO_MODES } ?: DEFAULT_SKIP_INTRO_MODE
@@ -0,0 +1,217 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaStream
import kotlinx.serialization.Serializable
import java.net.URI
import java.net.URLEncoder
@Serializable
data class PlayableSubtitle(
val id: String = "",
val url: String,
val mimeType: String,
val language: String? = null,
val label: String? = null,
val isDefault: Boolean = false,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
val deliveryMethod: String = "External",
val codec: String? = null,
)
internal fun subtitleTracks(
streams: List<MediaStream>,
serverUrl: String,
token: String,
itemId: String,
mediaSourceId: String,
): List<PlayableSubtitle> = streams.mapNotNull { stream ->
if (!stream.type.equals("Subtitle", true) || stream.index < 0) {
return@mapNotNull null
}
val sourceId = mediaSourceId.ifBlank { itemId }
val method = stream.deliveryMethod?.takeIf(String::isNotBlank)
?: if (stream.isTextSubtitleStream) "External" else "Encode"
val mime = subtitleMimeType(stream.codec, stream.deliveryUrl.orEmpty())
val extension = subtitleExtension(stream.codec, stream.deliveryUrl.orEmpty())
val canonical = "/Videos/${pathSegment(itemId)}/${pathSegment(sourceId)}" +
"/Subtitles/${stream.index}/Stream.$extension"
val delivery = stream.deliveryUrl?.takeIf(String::isNotBlank) ?: canonical
PlayableSubtitle(
id = stream.index.toString(),
url = if (method.equals("External", true) && mime != null) {
authenticatedDeliveryUrl(serverUrl, delivery, token)
} else "",
mimeType = mime.orEmpty(),
language = stream.language?.trim()?.takeIf(String::isNotEmpty),
label = (stream.displayTitle ?: stream.title)?.trim()?.takeIf(String::isNotEmpty),
isDefault = stream.isDefault,
isForced = stream.isForced,
isHearingImpaired = stream.isHearingImpaired || listOfNotNull(stream.title, stream.displayTitle).any {
it.contains("sdh", true) || it.contains("hearing", true)
},
deliveryMethod = method,
codec = stream.codec,
)
}.distinctBy { it.id }
/**
* "No preference": keep the flag-driven behaviour somebody who has never opened the
* subtitle menu should still get.
*/
const val SUBTITLE_LANGUAGE_AUTO = "auto"
/**
* ISO 639-1 codes are the vocabulary, because that is what media3 reports for a track after
* its own normalisation. The aliases are the three-letter forms Emby actually writes into a
* stream's language field — for several languages, two of them for the same thing.
*
* The same table exists in the gateway (`internal/api/subtitles.go`), which is what makes
* the two paths agree on what "Italian" means. Add a language to one and add it to both.
*/
private val subtitleLanguageAliases: Map<String, String> = buildMap {
fun language(code: String, vararg aliases: String) {
put(code, code)
aliases.forEach { put(it, code) }
}
language("en", "eng")
language("it", "ita")
language("es", "spa", "esp")
language("fr", "fre", "fra")
language("de", "ger", "deu")
language("pt", "por")
language("nl", "dut", "nld")
language("sv", "swe")
language("da", "dan")
language("no", "nor", "nob", "nno")
language("fi", "fin")
language("pl", "pol")
language("cs", "cze", "ces")
language("hu", "hun")
language("ro", "rum", "ron")
language("el", "gre", "ell")
language("ru", "rus")
language("uk", "ukr")
language("tr", "tur")
language("ar", "ara")
language("he", "heb", "iw")
language("hi", "hin")
language("ja", "jpn")
language("ko", "kor")
language("zh", "chi", "zho", "cmn", "yue")
language("th", "tha")
language("vi", "vie")
}
/**
* Folds a track's language onto that vocabulary. An unrecognised code survives as itself
* rather than becoming empty, so an exact match on a language the table has no entry for
* still works.
*/
fun normalizeSubtitleLanguage(raw: String?): String {
val value = raw?.trim()?.lowercase().orEmpty()
if (value.isEmpty()) return ""
val base = value.takeWhile { it != '-' && it != '_' }
return subtitleLanguageAliases[base] ?: base
}
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
data class SubtitleCandidate(
val id: String,
val language: String?,
val isDefault: Boolean = false,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
)
/**
* Which track to turn on, or null for none. The rules mirror `selectSubtitle` in the
* gateway exactly, because the direct-to-Emby path has no gateway to ask and a viewer must
* not get different subtitles depending on whether the container is up:
*
* - Off means off.
* - A chosen language wins, and within it a plain full track beats a forced or
* hearing-impaired one — somebody who picked Italian wants the dialogue, not the
* signs-only track that happens to come first.
* - A chosen language the title does not have falls back to a *forced* track only. Forced
* subtitles translate what is foreign to the film's own audio, so they are wanted either
* way; falling through to English instead would put a language nobody asked for on screen.
* - With no language chosen: forced, then default, then the first track — which is what an
* install that has never touched the setting keeps.
*/
fun selectSubtitleId(
candidates: List<SubtitleCandidate>,
enabled: Boolean,
language: String,
): String? {
if (!enabled || candidates.isEmpty()) return null
val preferred = normalizeSubtitleLanguage(language)
if (preferred.isNotEmpty() && preferred != SUBTITLE_LANGUAGE_AUTO) {
bestSubtitleInLanguage(candidates, preferred)?.let { return it }
return candidates.firstOrNull { it.isForced }?.id
}
return candidates.firstOrNull { it.isForced }?.id
?: candidates.firstOrNull { it.isDefault }?.id
?: candidates.first().id
}
// Forced sorts last within a language precisely because it is not a substitute for the full
// track somebody asked for.
private fun bestSubtitleInLanguage(
candidates: List<SubtitleCandidate>,
language: String,
): String? = candidates
.filter { normalizeSubtitleLanguage(it.language) == language }
.maxByOrNull {
when {
it.isForced -> 1
it.isHearingImpaired -> 2
it.isDefault -> 5
else -> 4
}
}
?.id
internal fun PlayableSubtitle.asCandidate(): SubtitleCandidate = SubtitleCandidate(
id = id,
language = language,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
)
private fun subtitleExtension(codec: String?, url: String): String =
when (codec?.trim()?.lowercase()) {
"subrip" -> "srt"
"webvtt" -> "vtt"
"tx3g" -> "mov_text"
else -> codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
?: url.substringBefore('?').substringAfterLast('.', "vtt").lowercase()
}
private fun pathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
internal fun subtitleMimeType(codec: String?, url: String = ""): String? =
when ((codec?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
?: url.substringBefore('?').substringAfterLast('.', "").lowercase())) {
"srt", "subrip" -> "application/x-subrip"
"vtt", "webvtt" -> "text/vtt"
"ass", "ssa" -> "text/x-ssa"
"ttml", "dfxp" -> "application/ttml+xml"
"tx3g", "mov_text" -> "application/x-quicktime-tx3g"
else -> null
}
internal fun authenticatedDeliveryUrl(serverUrl: String, deliveryUrl: String, token: String): String {
val absolute = if (runCatching { URI(deliveryUrl).isAbsolute }.getOrDefault(false)) {
deliveryUrl
} else {
serverUrl.trimEnd('/') + "/" + deliveryUrl.trimStart('/')
}
if (token.isBlank() || Regex("""(?:[?&])api_key=""", RegexOption.IGNORE_CASE).containsMatchIn(absolute)) {
return absolute
}
val separator = if ('?' in absolute) '&' else '?'
return absolute + separator + "api_key=" + URLEncoder.encode(token, "UTF-8")
}
@@ -0,0 +1,184 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayTheme
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
/**
* Keeps this television painted the colour the gateway says it should be.
*
* Three things move a theme and all three arrive the same way — as a revision on the status
* poll that no longer matches what this set holds:
*
* - **The viewer picks a scheme.** The choice is an ordinary synced setting, pushed by
* [PreferencesSync]; the *palette* comes back through here a moment later.
* - **The operator changes what they may choose.** A scheme withdrawn resolves to the
* default, and the set repaints without anybody signing in again.
* - **A season begins or ends.** This is the one that could not work any other way. Nobody
* writes anything at midnight on the 1st of December — the answer simply becomes
* different — which is why the revision is a hash of the resolved theme rather than a
* counter in a table, and why the poll is where it rides.
*
* The cached palette is applied first, before any request. A television that has been signed
* in before therefore starts in its own colours rather than opening in the default and
* flicking over a second later, which is the same promise `HomeCache` makes about the rows.
*
* Everything here fails silently. A colour is not worth an error message on a television,
* and the state a failure leaves — the palette already on screen, the revision not advanced
* — is a correct one to sit in until the next poll.
*/
class ThemeSync(
private val repository: EmbyRepository,
private val settings: SettingsStore,
private val remoteTheme: StateFlow<GatewayThemeStatus>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val json = Json { ignoreUnknownKeys = true }
private val _theme = MutableStateFlow<GatewayTheme?>(null)
/**
* The resolved theme as the server last described it: which scheme is on, whether it is
* a season, and the sentence saying so. Null until this set has been told once — which
* is what the settings picker renders as "not available" rather than as a locked state
* it has no evidence for.
*/
val theme: StateFlow<GatewayTheme?> = _theme.asStateFlow()
private val _available = MutableStateFlow<List<GatewayTheme>>(emptyList())
/**
* The schemes this viewer may choose between. Empty on the direct path and before the
* first fetch, and the picker draws nothing rather than a list compiled into the APK:
* the per-user allowlist is only real if a withheld theme is one the television was
* never sent.
*/
val available: StateFlow<List<GatewayTheme>> = _available.asStateFlow()
/** One fetch at a time; two racing would decide the stored revision twice. */
private val mutex = Mutex()
/**
* The revision whose palette is on screen. Held here as well as on disk because the
* write is skipped when nothing changed, so disk alone cannot say whether this process
* has already acted on a revision.
*/
private var appliedRevision: String? = null
init {
scope.launch { run() }
}
private suspend fun run() {
// Paint from the cache immediately, outside the lifecycle gate below: this has to
// happen while the launcher is composing its first frame, not when the process
// happens to reach the foreground.
scope.launch {
repository.settingsFlow
.distinctUntilChanged { old, new -> old.themePaletteJson == new.themePaletteJson }
.collect { session -> applyCached(session.themePaletteJson) }
}
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(repository.settingsFlow, remoteTheme) { session, status ->
SyncTrigger(session, status)
}
.distinctUntilChanged()
// Plain collect rather than collectLatest: a fetch cancelled halfway could
// leave the stored revision describing a palette that was never written.
.collect(::reconcile)
}
}
/** The only parts of the session and the poll a theme fetch depends on. */
private data class SyncTrigger(
val signedIn: Boolean,
val heldRevision: String,
val serverRevision: String,
) {
constructor(session: Settings, status: GatewayThemeStatus) : this(
signedIn = session.isSignedIn,
heldRevision = session.themeRevision,
serverRevision = status.revision,
)
}
private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway) return
if (!trigger.signedIn) {
// Back to the app's own colours. A sign-out that left the last viewer's scheme
// on the setup screen would be showing somebody's choice to whoever is about to
// replace them.
_theme.value = null
_available.value = emptyList()
appliedRevision = null
applyMembyPalette(MembyPalette())
return
}
// A server that predates themes sends nothing, and there is nothing to fetch. The
// palette already on screen — cached or default — is the right thing to keep.
if (trigger.serverRevision.isEmpty()) return
if (trigger.serverRevision == appliedRevision &&
trigger.serverRevision == trigger.heldRevision
) {
return
}
mutex.withLock { fetch(trigger.serverRevision) }
}
private suspend fun fetch(expectedRevision: String) {
if (appliedRevision == expectedRevision) return
val document = runCatching { repository.theme() }.getOrNull() ?: return
val resolved = document.theme
_theme.value = resolved
_available.value = document.available
val palette = resolved.palette.toMembyPalette()
applyMembyPalette(palette)
// The revision recorded is the one the *response* carried, not the one the poll
// advertised. They differ if a season turned over between the two, and storing the
// poll's would leave this set believing it holds a palette it never received.
appliedRevision = resolved.revision.ifEmpty { expectedRevision }
runCatching {
settings.setThemePalette(
json.encodeToString(com.ponzischeme89.memby.data.model.GatewayPalette.serializer(), resolved.palette),
appliedRevision.orEmpty(),
)
}.onFailure {
// The palette is on screen and simply not cached: this set repaints correctly
// now and pays one extra fetch on its next cold start. Clearing the applied
// revision would instead make it refetch on every poll.
}
}
/**
* Paints from what was stored at the end of the last session, before anything is asked
* of the network. A blank or unreadable cache leaves the default palette standing.
*/
private fun applyCached(paletteJson: String?) {
if (paletteJson.isNullOrBlank()) return
val palette = runCatching {
json.decodeFromString(
com.ponzischeme89.memby.data.model.GatewayPalette.serializer(),
paletteJson,
)
}.getOrNull() ?: return
applyMembyPalette(palette.toMembyPalette())
}
}
@@ -0,0 +1,60 @@
package com.ponzischeme89.memby.data
import androidx.compose.ui.graphics.Color
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.ui.theme.MembyPalette
/**
* Turning the gateway's answer into colours, and nothing else.
*
* There is deliberately **no client-side theme rule** here to match no seasonal
* calculation, no allowlist, no fallback catalogue. That is the opposite of the choice made
* for subtitles, intros and Continue Watching, where the rule exists twice because with no
* gateway there is nobody to ask. The difference is what "nobody to ask" costs: for those,
* the direct path would behave *differently*, which is a bug. Here it behaves as it always
* did the default palette, the one the app shipped with. A television painting itself
* Halloween orange on the strength of its own clock, while the household's server has the
* feature switched off, would be the feature failing rather than degrading.
*/
/**
* Parses `#AARRGGBB` (or `#RRGGBB`) as the gateway writes it.
*
* Alpha-first is Android's own order and the reason it is what goes on the wire: this end
* parses one of these for every colour of every theme change, and the admin console which
* parses eight, once is where the reordering for CSS happens instead.
*
* Returns null rather than a colour for anything it cannot read, so the caller can keep the
* app's own token for that slot. A theme drawn one colour wrong is a blemish; a screen drawn
* transparent because a hex string had a typo in it is a television nobody can use.
*/
internal fun parseThemeColor(value: String?): Color? {
val hex = value?.trim()?.removePrefix("#") ?: return null
if (hex.length != 6 && hex.length != 8) return null
if (!hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return null
val argb = hex.toLongOrNull(16) ?: return null
// Six digits are opaque. Emby's own artwork colours are written that way and it is the
// form somebody hand-editing a palette would reach for.
return Color(if (hex.length == 6) argb or 0xFF000000L else argb)
}
/**
* The palette to paint with, given what the server sent.
*
* [fallback] is the palette currently in force rather than the class defaults, the same
* distinction [decodeUserPreferences] draws: a response missing a colour must leave that one
* alone, not silently reset it. That is what lets the gateway grow a token before every
* television in the house has the release that knows about it and what makes a partial
* palette a partial change rather than a half-black screen.
*/
fun GatewayPalette.toMembyPalette(fallback: MembyPalette = MembyPalette()): MembyPalette =
MembyPalette(
surface = parseThemeColor(surface) ?: fallback.surface,
surfaceRaised = parseThemeColor(surfaceRaised) ?: fallback.surfaceRaised,
accent = parseThemeColor(accent) ?: fallback.accent,
onSurface = parseThemeColor(onSurface) ?: fallback.onSurface,
mutedText = parseThemeColor(mutedText) ?: fallback.mutedText,
quietText = parseThemeColor(quietText) ?: fallback.quietText,
hairline = parseThemeColor(hairline) ?: fallback.hairline,
ratingsSurface = parseThemeColor(ratingsSurface) ?: fallback.ratingsSurface,
)
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.data
/**
* The seek preview thumbnails for one title, and on the direct path where each one's
* bytes live inside Emby's file.
*
* Emby stores them as a BIF: a header, an index of (timestamp, offset) pairs, then one
* JPEG per entry laid end to end, at one frame every ten seconds. The index sitting at the
* *front* of the file is what makes previews affordable on a television. A two-hour film's
* BIF is five megabytes and nobody can download that to show one thumbnail, but reading
* the first few kilobytes gives every frame's byte range, so a preview costs one ranged
* request of about seven kilobytes.
*
* The parsing exists twice on purpose here and in the gateway's `internal/trickplay`
* and is pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`). With no
* gateway there is nobody to ask, and a preview must not depend on whether the container
* is up. In gateway mode the television has no Emby credential to range-read a file with,
* so the server does the reading and serves a frame at a time; [bif] is null there.
*/
data class Trickplay(
val itemId: String,
val intervalMs: Long,
val count: Int,
val width: Int = 0,
val height: Int = 0,
internal val bif: BifIndex? = null,
internal val bifUrl: String? = null,
) {
/**
* Which thumbnail covers a moment in the title.
*
* It clamps rather than refusing. This is read while somebody is still moving a seek
* target about, and a position a second past the last frame should show the last
* frame a preview that blanks at the end of a film reads as broken.
*/
fun frameAt(positionMs: Long): Int {
if (count <= 0 || intervalMs <= 0L) return 0
if (positionMs <= 0L) return 0
val frame = positionMs / intervalMs
return if (frame >= count) count - 1 else frame.toInt()
}
/**
* How wide to draw the preview for a given height. Falls back to 16:9 until the frames'
* real shape is known, so the plate is laid out at about the right size on the first
* press rather than growing a thumbnail-shaped hole when one arrives.
*/
fun widthFor(heightPx: Int): Int =
if (width > 0 && height > 0) heightPx * width / height else heightPx * 16 / 9
}
/** Where each frame's bytes are, read off the front of a BIF. */
data class BifIndex(
val count: Int,
val intervalMs: Long,
/** [count] + 1 values, the last being the end of the final frame. */
val offsets: List<Long>,
) {
/** The half-open byte range of one thumbnail, ready for a Range header. */
fun frame(index: Int): LongRange? {
if (index < 0 || index >= count || offsets.size <= count) return null
val start = offsets[index]
val end = offsets[index + 1]
return if (end > start) start until end else null
}
}
/**
* What reading the front of a BIF came to.
*
* [NeedMore] is the case that earns this its own type: a caller reads a fixed window off
* the front of the file, and a long title legitimately has an index that runs past it. That
* must be answerable read this much and try again rather than looking like a bad file.
*/
sealed interface BifParse {
data class Parsed(val index: BifIndex) : BifParse
data class NeedMore(val bytes: Int) : BifParse
data object NotBif : BifParse
}
/** The fixed preamble: magic, version, frame count, timestamp multiplier. */
const val BIF_HEADER_SIZE = 64
private const val BIF_ENTRY_SIZE = 8
private const val BIF_DEFAULT_MULTIPLIER = 1_000L
/**
* The file's first eight bytes. The leading 0x89 and the CR/LF pair are the trick PNG uses:
* a file mangled by a text-mode transfer stops matching.
*/
private val BIF_MAGIC = byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a)
/** How many bytes of the file hold the header and the whole index. */
fun bifIndexLength(count: Int): Int = BIF_HEADER_SIZE + (count + 1) * BIF_ENTRY_SIZE
/**
* Reads the header and index out of the front of a BIF. [bytes] may be longer than the
* index; the rest is ignored.
*
* A count of zero is not a failure. It is what Emby serves for a title whose thumbnails
* have not been generated a perfectly well-formed 72-byte file and it must read as
* "this title has no previews" or every such title looks like a broken one.
*/
fun parseBifIndex(bytes: ByteArray): BifParse {
if (bytes.size < BIF_HEADER_SIZE) return BifParse.NeedMore(BIF_HEADER_SIZE)
for (i in BIF_MAGIC.indices) {
if (bytes[i] != BIF_MAGIC[i]) return BifParse.NotBif
}
val count = readLittleEndian(bytes, 12).toInt()
if (count < 0) return BifParse.NotBif
val multiplier = readLittleEndian(bytes, 16).takeIf { it > 0L } ?: BIF_DEFAULT_MULTIPLIER
if (count == 0) {
return BifParse.Parsed(BifIndex(count = 0, intervalMs = multiplier, offsets = emptyList()))
}
val length = bifIndexLength(count)
if (bytes.size < length) return BifParse.NeedMore(length)
val offsets = ArrayList<Long>(count + 1)
var firstTimestamp = 0L
var secondTimestamp = 0L
for (entry in 0..count) {
val at = BIF_HEADER_SIZE + entry * BIF_ENTRY_SIZE
val timestamp = readLittleEndian(bytes, at)
offsets.add(readLittleEndian(bytes, at + 4))
when (entry) {
0 -> firstTimestamp = timestamp
1 -> secondTimestamp = timestamp
}
}
// A frame that starts inside the index, or before the one ahead of it, means the file
// is not laid out the way the format says. Reading a byte range from it would decode
// whatever happened to be there.
if (offsets[0] < length.toLong()) return BifParse.NotBif
for (entry in 1..count) {
if (offsets[entry] < offsets[entry - 1]) return BifParse.NotBif
}
// Emby writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the interval
// is ten seconds, and taking the multiplier for it would be right only by accident.
val interval = if (count >= 2 && secondTimestamp > firstTimestamp) {
(secondTimestamp - firstTimestamp) * multiplier
} else {
multiplier
}
return BifParse.Parsed(
BifIndex(
count = count,
intervalMs = if (interval > 0L) interval else BIF_DEFAULT_MULTIPLIER,
offsets = offsets,
),
)
}
private fun readLittleEndian(bytes: ByteArray, at: Int): Long =
(bytes[at].toLong() and 0xFF) or
((bytes[at + 1].toLong() and 0xFF) shl 8) or
((bytes[at + 2].toLong() and 0xFF) shl 16) or
((bytes[at + 3].toLong() and 0xFF) shl 24)
@@ -0,0 +1,193 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
/**
* The settings that belong to a *person* rather than to a television, in the shape both
* ends of the sync agree on.
*
* What is here and what is not is the whole design. These follow a viewer to any set they
* sign into and are the ones an operator can push. Deliberately absent: the device name,
* the update source and token, the screensaver's rotation interval and ring colour, and
* the last backdrop those describe the box in the living room, and carrying them across
* would rename someone's other television the moment they signed in on it.
*
* The class is a plain data holder with no Android or DataStore dependency so the
* conversions either side of it stay unit-testable.
*/
data class UserPreferences(
val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
/**
* The colour scheme this viewer chose, as a server theme id.
*
* Deliberately not validated on this side, unlike [seekIntervalSeconds] and
* [skipIntroMode]. Those normalise because a value this build cannot read would reach
* the player as a behaviour an unknown skip length, an unexplained jump. A theme id is
* only ever handed back to the gateway, which is the thing that decides what it means,
* and the palette that arrives is never derived from it here. So the safe treatment is
* to carry an unrecognised id through untouched, which is also what lets the server grow
* a theme before every television in the house has any idea it exists.
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
/**
* Whether a subtitle track is turned on automatically, and which language wins when it
* is. These sync for the same reason the rest do turning subtitles off in the bedroom
* should not leave them on in the living room and the gateway is what applies them,
* since it is the thing that asks Emby which tracks exist.
*/
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
/** How far Left and Right move the film, in seconds. One of [SEEK_INTERVAL_SECONDS]. */
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
/** Shrink the closing credits to one side at double speed with what is on next beside. */
val speedUpCredits: Boolean = true,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
val homeHiddenRows: List<String> = emptyList(),
) {
companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
}
}
/**
* What this television currently believes, ready to be pushed up.
*
* Reads the flat active-profile keys rather than the profile list: those are the values
* every screen actually renders from, so this is the state a viewer would recognise.
*/
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle,
showHomeCardMetadata = showHomeCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = themeId,
autoPlayNextEpisode = autoPlayNextEpisode,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
speedUpCredits = speedUpCredits,
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
homeHiddenRows = homeHiddenRows.decodeLineList(),
)
internal fun String.decodeCommaList(): List<String> =
split(',').map(String::trim).filter(String::isNotEmpty)
internal fun String.decodeLineList(): List<String> =
split('\n').map(String::trim).filter(String::isNotEmpty)
/**
* Decodes the server's document, keeping this build's value for anything it does not
* recognise or cannot read.
*
* [fallback] is the current local state rather than the class defaults, and that
* distinction matters: a server response missing a key must leave that setting alone, not
* silently reset it. That is what makes it safe for the gateway to grow a setting before
* every television in the house has the release that knows about it.
*/
fun decodeUserPreferences(
json: JsonObject,
fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences(
homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
themeId = json.string("themeId", fallback.themeId),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
// Normalised rather than trusted: the catalogue on the gateway may offer an interval
// this build has no vocabulary for, and a skip of an unknown length is worse than the
// default. A key the response does not carry leaves this television's value alone.
seekIntervalSeconds = normalizeSeekIntervalSeconds(
json.int("seekIntervalSeconds", fallback.seekIntervalSeconds),
),
// Normalised for the same reason the interval above is: a mode this build has no
// vocabulary for must cost the viewer the default button, never an unexplained jump.
skipIntroMode = normalizeSkipIntroMode(
json.string("skipIntroMode", fallback.skipIntroMode),
),
speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
homeHiddenRows = json.stringList("homeHiddenRows", fallback.homeHiddenRows),
)
/** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject {
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle)
put("showHomeCardMetadata", showHomeCardMetadata)
put("showRatingsStrip", showRatingsStrip)
put("hideWatchedMovies", hideWatchedMovies)
put("showTitleLogo", showTitleLogo)
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("themeId", themeId)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("skipIntroMode", skipIntroMode)
put("speedUpCredits", speedUpCredits)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homeHiddenRows") { homeHiddenRows.forEach { add(JsonPrimitive(it)) } }
}
private fun JsonObject.string(key: String, fallback: String): String =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: fallback
// A JSON string is not a boolean here: "false" arriving as text must fall back rather than
// be read as some truthy value, which is why isString is checked before the conversion.
private fun JsonObject.boolean(key: String, fallback: Boolean): Boolean =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull ?: fallback
private fun JsonObject.int(key: String, fallback: Int): Int =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.intOrNull ?: fallback
private fun JsonObject.stringList(key: String, fallback: List<String>): List<String> {
val array = this[key] as? JsonArray ?: return fallback
return array.mapNotNull { element ->
(element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct()
}
@@ -27,16 +27,31 @@ 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,16 +36,220 @@ 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,
/** When this item was last played. What orders the merged Continue Watching row. */
@SerialName("LastPlayedDate") val lastPlayedDate: String? = null,
) )
@Serializable @Serializable
data class PlaybackReport( data class PlaybackReport(
@SerialName("ItemId") val itemId: String, @SerialName("ItemId") val itemId: String,
@SerialName("MediaSourceId") val mediaSourceId: String,
@SerialName("PlaySessionId") val playSessionId: String,
@SerialName("PositionTicks") val positionTicks: Long = 0, @SerialName("PositionTicks") val positionTicks: Long = 0,
@SerialName("IsPaused") val isPaused: Boolean = false, @SerialName("IsPaused") val isPaused: Boolean = false,
@SerialName("IsMuted") val isMuted: Boolean = false, @SerialName("IsMuted") val isMuted: Boolean = false,
@SerialName("CanSeek") val canSeek: Boolean = true, @SerialName("CanSeek") val canSeek: Boolean = true,
@SerialName("PlayMethod") val playMethod: String = "DirectPlay", @SerialName("PlayMethod") val playMethod: String = "DirectPlay",
@SerialName("EventName") val eventName: String? = null,
)
@Serializable
data class PlaybackInfoRequest(
@SerialName("Id") val id: String,
@SerialName("UserId") val userId: String,
@SerialName("IsPlayback") val isPlayback: Boolean = true,
@SerialName("EnableDirectPlay") val enableDirectPlay: Boolean = true,
@SerialName("EnableDirectStream") val enableDirectStream: Boolean = true,
@SerialName("EnableTranscoding") val enableTranscoding: Boolean = true,
@SerialName("AllowVideoStreamCopy") val allowVideoStreamCopy: Boolean = true,
@SerialName("AllowAudioStreamCopy") val allowAudioStreamCopy: Boolean = true,
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
@SerialName("DeviceProfile") val deviceProfile: DeviceProfile = DeviceProfile.embyAndroidTv(),
)
@Serializable
data class DeviceProfile(
@SerialName("Name") val name: String,
@SerialName("SupportedMediaTypes") val supportedMediaTypes: String = "Video",
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
) {
companion object {
fun embyAndroidTv(
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
) = DeviceProfile(
name = "Memby Android TV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
"pgs", "pgssub", "sup", "vobsub", "dvdsub", "dvbsub",
).map { SubtitleProfile(it, "Encode") },
directPlayProfiles = listOf(
DirectPlayProfile(
// The broad codec declaration is bounded by CodecProfiles below.
container = "mkv,mp4,m4v,mov,ts,mpegts",
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac,mp3",
),
),
transcodingProfiles = listOf(
TranscodingProfile(
container = "ts",
// Permits Emby to remux a supported HEVC/H.264 video stream while
// converting only incompatible audio or subtitles.
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac",
protocol = "hls",
),
),
codecProfiles = codecProfiles(capabilities),
)
/** Compatibility for callers and older tests that only know the HEVC boolean. */
fun embyAndroidTv(supportsHevc: Boolean): DeviceProfile = embyAndroidTv(
DevicePlaybackCapabilities(
hevc = VideoDecoderCapabilities(
supported = supportsHevc,
profiles = if (supportsHevc) setOf("main") else emptySet(),
),
),
)
private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
if (capabilities.hevc.supported) "h264,hevc" else "h264"
private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<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
data class DirectPlayProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Type") val type: String = "Video",
)
@Serializable
data class TranscodingProfile(
@SerialName("Container") val container: String,
@SerialName("VideoCodec") val videoCodec: String,
@SerialName("AudioCodec") val audioCodec: String,
@SerialName("Protocol") val protocol: String,
@SerialName("Type") val type: String = "Video",
@SerialName("Context") val context: String = "Streaming",
)
@Serializable
data class SubtitleProfile(
@SerialName("Format") val format: String,
@SerialName("Method") val method: String,
) )
@Serializable @Serializable
@@ -52,9 +259,20 @@ data class Studio(
@Serializable @Serializable
data class MediaStream( data class MediaStream(
@SerialName("Index") val index: Int = -1,
@SerialName("Type") val type: String = "", @SerialName("Type") val type: String = "",
@SerialName("Codec") val codec: String? = null, @SerialName("Codec") val codec: String? = null,
@SerialName("Title") val title: String? = null, @SerialName("Title") val title: String? = null,
@SerialName("DisplayTitle") val displayTitle: String? = null,
@SerialName("Language") val language: String? = null,
@SerialName("IsDefault") val isDefault: Boolean = false,
@SerialName("IsForced") val isForced: Boolean = false,
@SerialName("IsHearingImpaired") val isHearingImpaired: Boolean = false,
@SerialName("IsExternal") val isExternal: Boolean = false,
@SerialName("IsTextSubtitleStream") val isTextSubtitleStream: Boolean = false,
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean = false,
@SerialName("DeliveryUrl") val deliveryUrl: String? = null,
@SerialName("DeliveryMethod") val deliveryMethod: String? = null,
@SerialName("Width") val width: Int? = null, @SerialName("Width") val width: Int? = null,
@SerialName("Height") val height: Int? = null, @SerialName("Height") val height: Int? = null,
@SerialName("VideoRange") val videoRange: String? = null, @SerialName("VideoRange") val videoRange: String? = null,
@@ -62,6 +280,46 @@ data class MediaStream(
@SerialName("Channels") val channels: Int? = null, @SerialName("Channels") val channels: Int? = null,
) )
@Serializable
data class PlaybackInfo(
@SerialName("MediaSources") val mediaSources: List<MediaSourceInfo> = emptyList(),
@SerialName("PlaySessionId") val playSessionId: String = "",
)
@Serializable
data class MediaSourceInfo(
@SerialName("Id") val id: String = "",
@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("TranscodingUrl") val transcodingUrl: String? = null,
)
@Serializable
data class EmbyPerson(
@SerialName("Id") val id: String = "",
@SerialName("Name") val name: String = "",
@SerialName("Role") val role: String? = null,
@SerialName("Type") val type: String = "",
@SerialName("PrimaryImageTag") val primaryImageTag: String? = null,
) {
val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true)
}
/**
* One entry of Emby's chapter list. Only two of its fields matter here: intro markers are
* written as ordinary chapters carrying a [markerType], in playback order beside the real
* ones, which is why finding the titles costs no request of its own.
*/
@Serializable
data class EmbyChapter(
@SerialName("StartPositionTicks") val startPositionTicks: Long = 0L,
@SerialName("MarkerType") val markerType: String = "",
@SerialName("Name") val name: String = "",
)
@Serializable @Serializable
data class BaseItem( data class BaseItem(
@SerialName("Id") val id: String, @SerialName("Id") val id: String,
@@ -70,27 +328,118 @@ data class BaseItem(
@SerialName("Overview") val overview: String? = null, @SerialName("Overview") val overview: String? = null,
@SerialName("Taglines") val taglines: List<String> = emptyList(), @SerialName("Taglines") val taglines: List<String> = emptyList(),
@SerialName("ProductionYear") val productionYear: Int? = null, @SerialName("ProductionYear") val productionYear: Int? = null,
// The day a title first aired or was released, as Emby's ISO-8601 string. An episode's
// is the one date that distinguishes it from its neighbours in a list, so it is asked
// for by name in every episode query — it is not a default field.
@SerialName("PremiereDate") val premiereDate: String? = null,
@SerialName("OfficialRating") val officialRating: String? = null, @SerialName("OfficialRating") val officialRating: String? = null,
@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("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null, @SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(), @SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(), @SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null, @SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null, @SerialName("SeriesName") val seriesName: String? = null,
/**
* Emby's production status for a series "Continuing" or "Ended". Only the detail
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
* absence as "not known to be running" rather than guessing either way.
*/
@SerialName("Status") val status: String? = null,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@SerialName("IndexNumber") val indexNumber: Int? = null,
@SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null, @SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null,
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(), @SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null, @SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null, @SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null, @SerialName("UserData") val userData: UserItemData? = null,
/**
* Chapter markers, which is where Emby records an episode's opening titles. Only the
* direct path's intro lookup asks for them every other query would be paying for a
* couple of dozen entries per item to render nothing.
*/
@SerialName("Chapters") val chapters: List<EmbyChapter> = emptyList(),
// Server-authored schedule metadata. These fields are absent on normal Emby items.
@SerialName("MembySource") val membySource: String? = null,
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
@SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null,
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
// Sonarr's or Radarr's own lifecycle for the title — whether more episodes are coming,
// whether the film has actually been released. Distinct from availability, which is
// about the household's copy. The slug is what the badge colours by; the text is the
// gateway's wording, so a status this build predates still reads correctly.
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// The Emby series a schedule card stands for, when the library holds it. Absent for a
// show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
// Derived by the TV from the weekly schedule row and retained in the local home cache.
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
// Explainability supplied only by the gateway's dedicated For You endpoint.
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
// Backend diagnostics for the shared explainable ranker. These remain optional so
// direct-to-Emby mode and older cached payloads decode unchanged.
@SerialName("MembyRecommendationScore") val membyRecommendationScore: Double? = null,
@SerialName("MembyRecommendationComponents")
val membyRecommendationComponents: Map<String, Double> = emptyMap(),
@SerialName("MembyRecommendationReasonCodes")
val membyRecommendationReasonCodes: List<String> = emptyList(),
@SerialName("MembyExploration") val membyExploration: Boolean = false,
// External ratings the gateway already had stored for this title. They ride on the
// card so a row can draw its scores as it appears rather than when focus reaches it;
// an item the gateway has never looked up carries none and the dedicated ratings
// request still fills it in. Defaulted, so a cached home payload decodes unchanged.
@SerialName("MembyRatings") val membyRatings: List<MediaRating> = emptyList(),
// Why this card is leading the launcher, decided by the gateway from evidence the
// television does not have — Radarr's digital release date, Sonarr's premieres and
// the stored review scores. The wording is the server's for the usual reason: a kind
// of hero card added tomorrow reads correctly on a build that predates it. Both are
// absent on the direct path, where the client picks the hero itself.
@SerialName("MembyHeroLabel") val membyHeroLabel: String? = null,
@SerialName("MembyHeroReason") val membyHeroReason: String? = null,
) { ) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true) val 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 isTvSchedule: Boolean get() = membySource == "sonarr"
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
/**
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
* one, since it knows about a season announced but not yet imported; Emby's own
* [status] is the fallback and the only source the direct path has. Neither present
* means no, which keeps the pace estimate saying "finish" the weaker claim.
*/
val isOngoingSeries: Boolean
get() = when {
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
else -> status.equals("Continuing", ignoreCase = true)
}
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
val episodeCode: String?
get() {
val episode = indexNumber ?: return null
val season = parentIndexNumber
return if (season != null) "S$season · E$episode" else "E$episode"
}
/** Runtime in whole minutes, or null when unknown. */ /** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int? val runtimeMinutes: Int?
@@ -15,6 +15,7 @@ data class GatewayLoginRequest(
val username: String, val username: String,
val password: String, val password: String,
val deviceId: String, val deviceId: String,
val deviceName: String,
) )
@Serializable @Serializable
@@ -25,6 +26,23 @@ data class GatewayLoginResponse(
val serverId: String = "", val serverId: String = "",
) )
@Serializable
data class GatewayDevice(
val deviceId: String,
val deviceName: String,
val clientVersion: String = "",
val lastSeenAt: String = "",
val current: Boolean = false,
)
@Serializable
data class GatewayDevices(
val devices: List<GatewayDevice> = emptyList(),
)
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
/** /**
* One horizontal strip, described entirely by the server. * One horizontal strip, described entirely by the server.
* *
@@ -49,7 +67,13 @@ data class HomeRow(
data class GatewayHome( data class GatewayHome(
/** Server-composed rows, in display order. */ /** Server-composed rows, in display order. */
val rows: List<HomeRow> = emptyList(), val rows: List<HomeRow> = emptyList(),
/** In progress, including the episode after one just finished — they are one row. */
val continueWatching: List<BaseItem> = emptyList(), val continueWatching: List<BaseItem> = emptyList(),
/**
* Still sent, and still merged into [continueWatching] by the gateway. It remains on
* the wire for televisions running a build that predates the merge, which compose a
* Next Up row of their own from it.
*/
val nextUp: List<BaseItem> = emptyList(), val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(), val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(), val latestMovies: List<BaseItem> = emptyList(),
@@ -60,8 +84,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(
@@ -69,6 +93,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
@@ -83,23 +109,541 @@ data class GatewayUpdate(
} }
} }
/** Lightweight live state returned even while normal gateway routes are in maintenance. */
@Serializable
data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
val alerts: List<GatewayAlert> = emptyList(),
val compatible: Boolean = true,
val compatibilityMessage: String = "",
val clientVersion: String = "",
val clientProtocol: String = "",
val gatewayVersion: String = "",
val serverProtocol: Int = 0,
val featureSchemaVersion: Int = 0,
val featureRevision: Long = 0,
val safeMode: Boolean = false,
val features: Map<String, Boolean> = emptyMap(),
/**
* Whether *Emby* is answering, as opposed to whether the gateway is. Absent from a
* server older than this field, which is why it defaults to a healthy, unmonitored
* reading rather than to null an app that cannot tell must not claim an outage.
*/
val emby: GatewayEmbyHealth = GatewayEmbyHealth(),
/**
* Revision of this viewer's server-held settings. The app compares it with what it
* has and fetches the document only when they differ, so an operator's push arrives
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
/**
* The colour scheme this viewer's televisions should be painted, as an id and a
* revision rather than the palette itself the [preferencesRevision] precedent, for
* the same reason: this poll runs every ten seconds on every open set, and a palette
* riding it would be eight colours repeated six times a minute to say nothing new.
*
* A server that predates this sends none, which decodes to a revision of "" and so
* never triggers a fetch: an app that cannot be told its theme keeps the one it shipped
* with, which is the palette everything looked like before themes existed.
*/
val theme: GatewayThemeStatus = GatewayThemeStatus(),
)
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
@Serializable
data class GatewayThemeStatus(
val id: String = "",
/**
* Opaque, and compared only for equality. It moves when the palette would look
* different which includes the morning a season begins, an event no revision counter
* in a table could produce because nobody wrote anything.
*/
val revision: String = "",
val seasonal: Boolean = false,
val locked: Boolean = false,
)
/**
* The gateway's live reading of its own connection to Emby.
*
* [monitored] is the load-bearing field: with the probe switched off nothing updates
* [reachable], so a client that ignored this would show a permanent, wrong red bar. The
* server also sends `reachable = true` in that case, but the app should not depend on both
* halves of a defence.
*/
@Serializable
data class GatewayEmbyHealth(
val monitored: Boolean = false,
val reachable: Boolean = true,
/** When the current state began, RFC3339. */
val since: String = "",
/** When the last probe ran, RFC3339. The retry countdown is measured from here. */
val checkedAt: String = "",
val retrySeconds: Int = 0,
) {
/** An outage worth telling the viewer about: monitored, and currently failing. */
val isOutage: Boolean get() = monitored && !reachable
}
/**
* A viewer's settings as the gateway holds them, so they follow the person to whichever
* television they sit in front of.
*
* [preferences] stays a [JsonObject] on the wire rather than a typed class on purpose: a
* server that has learned a new setting must not make this response undecodable on an app
* that has not. [com.ponzischeme89.memby.data.UserPreferences] is where it becomes typed,
* and everything it does not recognise is carried through untouched.
*/
@Serializable
data class GatewayPreferences(
val schemaVersion: Int = 0,
val revision: Long = 0,
val updatedAt: String = "",
/** "device" or "admin" — who wrote it last. */
val source: String = "",
val preferences: kotlinx.serialization.json.JsonObject =
kotlinx.serialization.json.JsonObject(emptyMap()),
)
/**
* The colour scheme in force and the ones this viewer may choose between.
*
* Both halves come from the server and neither is compiled into the app. The catalogue is
* per viewer, not per app: a theme the operator has withheld from somebody is not a greyed
* row on their television, it is a row that was never sent which is what makes the
* per-user allowlist real rather than advisory.
*/
@Serializable
data class GatewayThemeDocument(
val schemaVersion: Int = 0,
val theme: GatewayTheme = GatewayTheme(),
val available: List<GatewayTheme> = emptyList(),
)
/**
* One theme. [palette] is the whole of what it changes a theme never moves a control or
* alters what a row contains, so the worst a scheme this build has never heard of can do is
* look wrong.
*/
@Serializable
data class GatewayTheme(
val id: String = "",
val name: String = "",
val description: String = "",
/**
* True for Halloween, Christmas and Easter. Never offered as a choice and never stored
* as one; it is simply in force for its dates.
*/
val seasonal: Boolean = false,
/**
* While true the picker shows the viewer's own choice but will not let them change it,
* and [reason] is the sentence explaining why. Distinct from [seasonal] so a future
* reason to pin a theme does not have to claim to be a season.
*/
val locked: Boolean = false,
/** The viewer's own selection, still theirs underneath a season. */
val chosen: String = "",
/** The server's wording for the lock, so a season invented later still reads correctly. */
val reason: String = "",
/**
* What drifts over the launcher while this theme is on: "snow", "bats", "blossom", or
* empty for the whole rest of the year and for every scheme somebody chose themselves.
*
* A slug rather than anything describing the animation the drawing is the television's,
* in `ui/seasonal/`. A build that does not recognise one draws nothing, so the gateway may
* invent a decoration before the fleet has the release that knows it. Empty is also what
* an operator who has turned decorations off gets, which is why the client must obey this
* field rather than deriving the animation from the theme id.
*/
val decoration: String = "",
val revision: String = "",
val palette: GatewayPalette = GatewayPalette(),
)
/**
* The eight colours a theme sets, as `#AARRGGBB`. Alpha first because Android is the end
* that has to parse thousands of these; the admin console reorders for CSS at its own end.
*
* Every field defaults to blank rather than to a colour: a missing one falls back to the
* app's own token at the point of conversion, which is a theme drawn slightly wrong rather
* than a screen drawn transparent.
*/
@Serializable
data class GatewayPalette(
val surface: String = "",
val surfaceRaised: String = "",
val accent: String = "",
val onSurface: String = "",
val mutedText: String = "",
val quietText: String = "",
val hairline: String = "",
val ratingsSurface: String = "",
)
/** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */
@Serializable
data class GatewayPreferencesRequest(
val revision: Long,
val preferences: kotlinx.serialization.json.JsonObject,
)
@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 "this episode aired and is on
* its way into Emby", or "Radarr just imported this film". It is never actionable: the
* banner slides in, states the news and leaves. Unknown [kind] values still render, and
* the server supplies the wording, so a new kind of news needs no app release.
*/
@Serializable
data class GatewayAlert(
val id: String = "",
val kind: String = "",
/** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */
val label: String = "",
val title: String = "",
val message: String = "",
val itemId: String = "",
val imageTag: String = "",
val airedAt: String = "",
)
/** Response of `GET /v1/recommendations`. */ /** Response of `GET /v1/recommendations`. */
@Serializable @Serializable
data class GatewayRows( data class GatewayRows(
val rows: List<HomeRow> = emptyList(), val rows: List<HomeRow> = emptyList(),
) )
/** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable
data class MediaRating(
val source: String = "",
val name: String = "",
val score: String = "",
val scale: String = "",
)
@Serializable
data class GatewayMovieRatings(
val ratings: List<MediaRating> = emptyList(),
)
@Deprecated("Use MediaRating")
typealias GatewayMovieRating = MediaRating
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
// Defaults true for compatibility with gateways released before prompting became
// server-controlled; current gateways always send the field explicitly.
val prompted: Boolean = true,
val ratings: Map<String, Int> = emptyMap(),
val items: List<BaseItem> = emptyList(),
val movies: List<BaseItem> = emptyList(),
val shows: List<BaseItem> = emptyList(),
val actors: List<RecommendationPerson> = emptyList(),
val actresses: List<RecommendationPerson> = emptyList(),
val directors: List<RecommendationPerson> = emptyList(),
)
@Serializable
data class RecommendationPreferences(
val ratings: Map<String, Int> = emptyMap(),
val actors: List<String> = emptyList(),
val actresses: List<String> = emptyList(),
val directors: List<String> = emptyList(),
)
@Serializable
data class RecommendationPerson(
val id: String = "",
val name: String = "",
val imageTag: String = "",
)
@Serializable @Serializable
data class GatewayItems( data class GatewayItems(
val items: List<BaseItem> = emptyList(), val items: List<BaseItem> = emptyList(),
) )
/**
* One page of `GET /v1/genres/{genre}/items` or `GET /v1/library/items`.
*
* [total] is what ends the scroll. A page shorter than [limit] ends it too, but a genre
* whose last page happens to divide evenly would otherwise cost one more empty request to
* discover that, and that request lands exactly as somebody reaches the bottom of the grid.
* It defaults to zero rather than to something optimistic: a gateway that answered without
* it must leave the television believing it has everything, not asking forever.
*/
@Serializable
data class GatewayGenrePage(
val genre: String = "",
val items: List<BaseItem> = emptyList(),
val offset: Int = 0,
val limit: Int = 0,
val total: Int = 0,
)
/**
* 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
data class GatewaySearchHistory(
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
data class GatewayPrerollSchedule(
val today: List<GatewayPrerollEntry> = emptyList(),
val thisWeek: List<GatewayPrerollEntry> = emptyList(),
)
@Serializable
data class GatewayPrerollEntry(
val itemId: String = "",
val imageType: String = "",
val series: String = "",
val episode: String = "",
val episodeCode: String = "",
val schedule: String = "",
val availability: String = "",
)
@Serializable @Serializable
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(),
// Which of those tracks the gateway decided to turn on, from the viewer's synced
// settings. Absent on an older gateway, which is what the defaults describe: subtitles
// on, nothing chosen, so the television falls back to its own flag-driven pick.
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
// Whether this gateway can fetch a subtitle the title does not have. It rides on the
// playback response rather than the status poll because the drop-up is the only thing
// that asks and it already holds this. Absent on an older gateway, and the default
// must stay false: a missing field must never conjure a row that cannot do anything.
val subtitleDownloadAvailable: Boolean = false,
// Whether this title has another readable text track against which subtitle timing can
// be checked. False for an older gateway, so a missing field never creates a dead row.
val subtitleFixAvailable: Boolean = false,
// Whether it is worth asking this gateway for seek previews at all. Only the answer
// rides here — the layout itself is its own request, off the critical path of
// starting playback. Absent on an older gateway, and the default must stay false: a
// missing field must never conjure a request the backend would 404.
val trickplayAvailable: Boolean = false,
// Whether it is worth asking this gateway where the title sequence is. Same shape and
// same reasoning as the previews above: the segment itself is its own request, and a
// missing field must never conjure one this backend would not answer.
val skipIntroAvailable: Boolean = false,
// Whether it is worth asking this gateway where the closing credits begin. Same shape
// and same reasoning again, and deliberately its own field rather than a reuse of
// [skipIntroAvailable]: they are separate features with separate switches, and a house
// that turned the skip button off has not asked to lose the credits pane with it.
val endCreditsAvailable: Boolean = false,
)
/**
* Where an episode's opening titles sit, and where its closing credits begin, as the gateway
* found them in Emby's markers.
*
* [available] is explicit rather than implied by a zero pair: an intro can legitimately
* begin at the very start of the file, and that must stay distinguishable from an episode
* that has no markers at all.
*
* Both answers share one response because they are in the same chapter list, so reading them
* together costs the one Emby request the gateway was always going to make. [creditsAvailable]
* is separate from [available] because an episode routinely has one and not the other every
* film Emby has found credits but no intro in would otherwise be lost.
*/
@Serializable
data class GatewayIntro(
val available: Boolean = false,
val startMs: Long = 0L,
val endMs: Long = 0L,
val creditsAvailable: Boolean = false,
val creditsStartMs: Long = 0L,
)
/**
* How a title's seek previews are laid out, as the gateway describes them.
*
* Frame URLs are not listed. There are hundreds of them and they are formed by a rule the
* client already knows, so a list would be most of the response.
*/
@Serializable
data class GatewayTrickplay(
val available: Boolean = false,
val intervalMs: Long = 0L,
val count: Int = 0,
val width: Int = 0,
val height: Int = 0,
)
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@Serializable
data class GatewaySubtitleCandidate(
// Which backend produced this row. It rides back with the token on the download call,
// because the gateway dispatches on it — the providers' tokens are opaque in different
// ways and handing one to the other is a mistake nothing could detect. Never derived
// here: an empty value is a gateway that predates the second provider, and the server
// reads that as Bazarr.
val source: String = "",
// The provider's opaque handle. It round-trips untouched — nothing on this side parses
// it, and reconstructing it from the other fields would break the download.
val token: String = "",
val language: String = "",
val languageLabel: String = "",
val provider: String = "",
val score: Int = 0,
val forced: Boolean = false,
val hearingImpaired: Boolean = false,
val originalFormat: Boolean = false,
// Whether nobody wrote this translation. It is on the wire rather than only in the
// label because it is the one property that changes whether a viewer wants the row at
// all, and the gateway's ranking sinks it below everything a person wrote.
val machineOnly: Boolean = false,
// What the row prints. Composed by the gateway so an app that predates a new wording
// still renders it correctly, the same reason alert labels are the gateway's.
val label: String = "",
)
@Serializable
data class GatewaySubtitleSearch(
val results: List<GatewaySubtitleCandidate> = emptyList(),
// Why there are none, when there are none. "Nothing was found" and "Memby could not
// work out which title this is" are different answers and a viewer deserves to know
// which one they got.
val message: String = "",
)
@Serializable
data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate)
@Serializable
data class GatewaySubtitleFixRequest(val subtitleId: String)
/** The result of checking one subtitle's timing against another track on the title. */
@Serializable
data class GatewaySubtitleFix(
val subtitleId: String = "",
val message: String = "",
val changed: Boolean = false,
val offsetMs: Long = 0L,
val reference: String = "",
)
/**
* The result of a download: the item's tracks re-read from Emby after it was told to look
* again, so the player can swap its media item and turn the new track on without a second
* round trip.
*/
@Serializable
data class GatewaySubtitleDownload(
val message: String = "",
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val url: String = "",
)
/**
* The episode that follows the one being watched. [item] is Emby's item JSON forwarded
* verbatim, so it decodes into the same [BaseItem] used everywhere else.
*/
@Serializable
data class GatewayNextEpisode(
val item: BaseItem,
val title: String = "",
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
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
@@ -107,6 +651,59 @@ 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(
@@ -127,5 +724,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 playSessionId: String = "",
val playMethod: String = "DirectPlay",
val eventName: String? = null,
)
@Serializable
data class GatewayPlaybackReportResponse(
val autoFollowedShowTitle: String = "",
) )
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.data.model
fun MediaRating.wordmark(): String = when (source.lowercase()) {
"tomatoes" -> "RT"
"audience" -> "RT Audience"
"metacritic" -> "Metacritic"
"letterboxd" -> "Letterboxd"
"mal" -> "MAL"
"anilist" -> "AniList"
"anidb" -> "AniDB"
"kitsu" -> "Kitsu"
"imdb" -> "IMDb"
"tmdb" -> "TMDb"
"trakt" -> "Trakt"
else -> name.trim()
}
fun MediaRating.formattedScore(): String = when (source.lowercase()) {
"tomatoes", "audience", "trakt", "anilist", "kitsu" -> "$score%"
else -> score
}
fun List<MediaRating>.displayable(): List<MediaRating> = filter { rating ->
rating.wordmark().isNotBlank() && rating.score.toDoubleOrNull()?.let { it > 0.0 } == true
}.distinctBy { it.source.lowercase() }
fun ratingDisplayLimit(widthDp: Int): Int = when {
widthDp < 120 -> 1
widthDp < 220 -> 2
widthDp < 360 -> 3
else -> Int.MAX_VALUE
}
fun ratingsStripVisible(enabled: Boolean, ratings: List<MediaRating>): Boolean =
enabled && ratings.displayable().isNotEmpty()
@@ -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,
)
}
}
@@ -4,6 +4,8 @@ import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.AuthResult import com.ponzischeme89.memby.data.model.AuthResult
import com.ponzischeme89.memby.data.model.ItemsResult import com.ponzischeme89.memby.data.model.ItemsResult
import com.ponzischeme89.memby.data.model.PlaybackReport import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfo
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
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.DELETE import retrofit2.http.DELETE
@@ -18,6 +20,14 @@ interface EmbyApi {
@POST("Users/AuthenticateByName") @POST("Users/AuthenticateByName")
suspend fun authenticate(@Body body: AuthRequest): AuthResult suspend fun authenticate(@Body body: AuthRequest): AuthResult
/**
* The reachability probe on the direct path. Unauthenticated and tiny by design: a
* probe that needed a token would report a stale session as a server outage, and the
* response is discarded only whether it arrived is the answer.
*/
@GET("System/Info/Public")
suspend fun systemInfoPublic(): okhttp3.ResponseBody
@GET("Users/{userId}/Items") @GET("Users/{userId}/Items")
suspend fun getItems( suspend fun getItems(
@Path("userId") userId: String, @Path("userId") userId: String,
@@ -31,12 +41,36 @@ interface EmbyApi {
@Query("Fields") fields: String, @Query("Fields") fields: String,
): com.ponzischeme89.memby.data.model.BaseItem ): com.ponzischeme89.memby.data.model.BaseItem
@POST("Items/{itemId}/PlaybackInfo")
suspend fun getPlaybackInfo(
@Path("itemId") itemId: String,
@Query("UserId") userId: String,
@Query("IsPlayback") isPlayback: Boolean = true,
@Body body: PlaybackInfoRequest,
): PlaybackInfo
@GET("Users/{userId}/Items/Resume")
suspend fun getResumeItems(
@Path("userId") userId: String,
@QueryMap params: Map<String, String>,
): ItemsResult
@GET("Users/{userId}/Items/{itemId}/LocalTrailers") @GET("Users/{userId}/Items/{itemId}/LocalTrailers")
suspend fun getLocalTrailers( suspend fun getLocalTrailers(
@Path("userId") userId: String, @Path("userId") userId: String,
@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
@@ -78,4 +112,12 @@ interface EmbyApi {
@Path("userId") userId: String, @Path("userId") userId: String,
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): UserItemData ): UserItemData
/** Hides an item from Emby's resume/next-up feeds without changing watched state. */
@POST("Users/{userId}/Items/{itemId}/HideFromResume")
suspend fun hideFromResume(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
@Query("Hide") hide: Boolean = true,
): UserItemData
} }
@@ -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))
@@ -60,8 +61,11 @@ private class EmbyAuthInterceptor(
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val deviceId = deviceIdProvider() val deviceId = deviceIdProvider()
// Version comes from the build, so Emby's device list shows which release a TV // Version comes from the build, so Emby's device list shows which release a TV
// is actually running. // is actually running. The client name is deliberately not the app's own — this
val authHeader = "MediaBrowser Client=\"Memby\", " + // header leaves the house with whatever Emby does with its logs — and must match
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
// ways would appear as two clients.
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\"" "Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
val builder = chain.request().newBuilder() val builder = chain.request().newBuilder()
@@ -2,19 +2,34 @@ 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.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.GatewayMovieRatings
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
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.GatewayRequestLookup
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
@@ -33,6 +48,22 @@ interface GatewayApi {
@POST("v1/auth/logout") @POST("v1/auth/logout")
suspend fun logout() suspend fun logout()
/** Confirms that a token restored from TV storage still exists on the gateway. */
@GET("v1/auth/session")
suspend fun session(): GatewayLoginResponse
@GET("v1/auth/devices")
suspend fun devices(): GatewayDevices
@DELETE("v1/auth/devices/{deviceId}")
suspend fun removeDevice(@Path("deviceId") deviceId: String)
@PUT("v1/auth/devices/{deviceId}")
suspend fun renameDevice(
@Path("deviceId") deviceId: String,
@Body body: GatewayDeviceNameRequest,
)
@GET("v1/home") @GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -42,10 +73,54 @@ interface GatewayApi {
@GET("v1/search") @GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
* so the gateway can ask Emby the question actually being asked.
*/
@GET("v1/genres/{genre}/items")
suspend fun genreItems(
@Path("genre") genre: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/** One paged media-type shelf without a genre filter. */
@GET("v1/library/items")
suspend fun libraryItems(
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
@POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>)
@GET("v1/search/history")
suspend fun recentSearches(): GatewaySearchHistory
@GET("v1/requests/lookup")
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
@POST("v1/requests")
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */ /** 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
@GET("v1/for-you")
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
@GET("v1/recommendations/preferences")
suspend fun recommendationPreferences(): RecommendationOnboarding
@PUT("v1/recommendations/preferences")
suspend fun saveRecommendationPreferences(@Body body: RecommendationPreferences)
@GET("v1/preroll")
suspend fun prerollSchedule(): GatewayPrerollSchedule
/** /**
* Whether this build should update. The version travels as a header on every request * Whether this build should update. The version travels as a header on every request
* (see GatewayServiceFactory), so there is nothing to pass here. * (see GatewayServiceFactory), so there is nothing to pass here.
@@ -53,11 +128,147 @@ 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. */
@GET("v1/status")
suspend fun serviceStatus(): GatewayServiceStatus
/** Versioned server control-plane document; unknown flags remain safely ignorable. */
@GET("v1/features")
suspend fun features(): GatewayFeatures
/**
* The colour scheme in force and the ones this viewer may pick between. Fetched only
* when the revision on the status poll moves see `ThemeSync`.
*/
@GET("v1/theme")
suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument
/** This viewer's settings as the server holds them, for whichever TV they sit at. */
@GET("v1/preferences")
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
/**
* Writes them back. A 409 means somebody else usually the operator got there
* first; the response body is their document, so the caller adopts rather than retries.
*/
@PUT("v1/preferences")
suspend fun savePreferences(
@Body body: com.ponzischeme89.memby.data.model.GatewayPreferencesRequest,
): com.ponzischeme89.memby.data.model.GatewayPreferences
@GET("v1/items/{id}") @GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem suspend fun item(@Path("id") itemId: String): BaseItem
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@GET("v1/items/{id}/season-finale")
suspend fun seasonFinale(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewaySeasonFinale
/** All episodes for a series in display order; the client groups them into seasons. */
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
/** Why this viewer might enjoy the item, and what else in the library is like it. */
@GET("v1/items/{id}/related")
suspend fun related(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayRelated
@GET("v1/items/{id}/playback") @GET("v1/items/{id}/playback")
suspend fun playback(@Path("id") itemId: String): GatewayPlayback suspend fun playback(
@Path("id") itemId: String,
@Query("type") itemType: String,
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
@Query("subtitleIndex") subtitleIndex: Int? = null,
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/**
* Where this episode's opening titles are, if Emby has marked them.
*
* Its own request for the same reason [trickplay] is: reading the markers costs the
* gateway a round trip to Emby, and nothing about a skip button is needed before the
* first frame the earliest intro in a typical library starts a couple of minutes in.
*/
@GET("v1/items/{id}/intro")
suspend fun intro(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayIntro
/**
* How this title's seek previews are laid out, if it has any.
*
* Deliberately its own request rather than a field on [playback]: reading the layout
* costs the gateway a round trip to Emby, and the playback response is the one thing
* standing between a Play press and a decoder starting. This is asked for once the
* first frame is up.
*/
@GET("v1/items/{id}/trickplay")
suspend fun trickplay(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayTrickplay
/**
* Ask the subtitle service for tracks this title does not have. This is a live query
* against subtitle providers, so it is slow by nature seconds, not milliseconds.
* 404 when the gateway has no subtitle service or the operator has turned it off.
*/
@GET("v1/items/{id}/subtitles/search")
suspend fun searchSubtitles(
@Path("id") itemId: String,
@Query("language") language: String? = null,
): com.ponzischeme89.memby.data.model.GatewaySubtitleSearch
/** Fetch one of those tracks. The gateway waits for Emby to notice the new file. */
@POST("v1/items/{id}/subtitles/download")
suspend fun downloadSubtitle(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload
/** Check one existing subtitle against another and store a corrected copy when needed. */
@POST("v1/items/{id}/subtitles/fix")
suspend fun fixSubtitle(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleFix
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@Path("id") itemId: String,
@Query("seriesId") seriesId: String,
): GatewayNextEpisode
@GET("v1/items/{id}/trailer") @GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -68,8 +279,14 @@ interface GatewayApi {
@POST("v1/items/{id}/played") @POST("v1/items/{id}/played")
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@POST("v1/items/{id}/hide-from-resume")
suspend fun hideFromResume(@Path("id") itemId: String): 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.
@@ -51,9 +54,36 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
// The gateway decides whether this build needs updating, so every request // The gateway decides whether this build needs updating, so every request
// 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-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")
} }
return chain.proceed(builder.build()) return chain.proceed(builder.build())
} }
} }
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",
"genre_browser_v1",
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
// Declares that this build can draw seek preview thumbnails, so the admin console
// reports the feature honestly against an older app that would never ask for them.
"trickplay_v1",
// Declares that this build can offer to skip an episode's opening titles, so the admin
// console reports the feature honestly against an older app that would never ask.
"skip_intro_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -0,0 +1,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,74 @@
package com.ponzischeme89.memby.data.remote
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* Reads seek preview thumbnails, which are the one thing in the app fetched as raw bytes
* rather than through Retrofit or Coil.
*
* Retrofit is the wrong shape because the direct path wants a *byte range* of a file, and
* Coil is the wrong home because these are small, transient and asked for in bursts: a
* viewer holding Right walks through dozens of them, and letting that churn through the
* artwork cache would evict the backdrops and posters the launcher is about to want again.
* The player keeps its own small cache instead.
*/
internal object TrickplayClient {
/**
* A preview is worth having only while somebody is still pressing. Eight seconds is
* already far past the point where the thumbnail would have answered the question, and
* the seek it is describing has committed and moved on.
*/
private const val TIMEOUT_SECONDS = 8L
/** A frame is a few kilobytes and an index a few more. Nothing here is megabytes. */
private const val MAX_BYTES = 4L * 1024L * 1024L
private const val CHUNK_BYTES = 16 * 1024
private val http by lazy {
HttpStack.base.newBuilder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Fetches [url], optionally only the bytes in [range].
*
* Returns null on any failure. Nothing above this can do anything useful with the
* reason: a preview that does not arrive leaves the seek indicator in the wordless form
* it has always had, which is a complete answer rather than a degraded one.
*
* A server that ignores the range answers 200 with the whole file. Emby does honour it
* on the BIF route while advertising `Accept-Ranges: none`, so the request is made on
* the strength of what comes back but the read is capped either way, because being
* wrong about that must not turn a seek into a five-megabyte download.
*/
suspend fun fetch(url: String, range: LongRange? = null): ByteArray? =
withContext(Dispatchers.IO) {
val builder = Request.Builder().url(url)
range?.let { builder.header("Range", "bytes=${it.first}-${it.last}") }
val limit = (range?.let { it.last - it.first + 1 } ?: MAX_BYTES)
.coerceIn(0L, MAX_BYTES)
runCatching {
http.newCall(builder.build()).execute().use { response ->
val stream = response.body?.byteStream()
if (!response.isSuccessful || stream == null) return@use null
val collected = ByteArrayOutputStream()
val chunk = ByteArray(CHUNK_BYTES)
while (collected.size() < limit) {
val wanted = minOf(chunk.size.toLong(), limit - collected.size()).toInt()
val read = stream.read(chunk, 0, wanted)
if (read <= 0) break
collected.write(chunk, 0, read)
}
collected.toByteArray().takeIf { it.isNotEmpty() }
}
}.getOrNull()
}
}
@@ -0,0 +1,972 @@
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.AiringNotice
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.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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
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.
//
// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette
// comes from the server, and an alias that captured a value would pin this whole page to
// whichever theme was loaded when the class first initialised.
internal val DetailBackground: Color get() = MembySurface
internal val DetailAccent: Color get() = MembyAccent
internal val DetailText: Color get() = MembyOnSurface
internal val DetailMutedText: Color get() = MembyMutedText
internal val DetailQuietText: Color get() = MembyQuietText
internal val DetailHairline: Color get() = MembyHairline
internal val DetailSideGutter = 58.dp
/**
* The band anchored under the hero: the tab strip on a movie or series page, the season
* scroller on an episode's. One height for both, because the fold it defines is the same
* fold, and a page whose content starts at a different place depending on what it is about
* reads as two designs.
*/
internal val DetailStripHeight = 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)
/**
* Hands focus to the first target that is actually on screen, and says whether any took it.
*
* `focusProperties { down = }` names exactly one node and throws when that node is not
* attached which on these pages is an ordinary state rather than an error. An episode
* page has no season chips until the seasons arrive, a season scrolled out of its LazyRow
* is not composed, a tab can be selected before its pane holds anything focusable. Down
* has to do the obvious thing in every one of those cases, so the direction keys name a
* *list* of places to try and fall through to the next rather than going dead under
* somebody's thumb.
*/
internal fun focusFirstAvailable(vararg targets: FocusRequester?): Boolean {
targets.forEach { target ->
if (target != null && runCatching { target.requestFocus() }.isSuccess) return true
}
return false
}
/**
* Vertical navigation stated as intent rather than as a destination.
*
* Deliberately `onKeyEvent` and not the preview: a press is offered to whatever holds
* focus first, so a control that means something of its own by Up or Down keeps it, and
* only an otherwise unhandled press is routed. Returning false leaves Compose's own focus
* search to try, which is the right last resort. Left and Right are never touched.
*/
internal fun Modifier.onVerticalNavigation(
up: (() -> Boolean)? = null,
down: (() -> Boolean)? = null,
): Modifier = onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionUp -> up?.invoke() ?: false
Key.DirectionDown -> down?.invoke() ?: false
else -> false
}
}
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 DetailBackground.copy(alpha = 0.95f),
0.42f to DetailBackground.copy(alpha = 0.80f),
0.72f to DetailBackground.copy(alpha = 0.22f),
1f to DetailBackground.copy(alpha = 0.06f),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x18000000),
0.48f to DetailBackground.copy(alpha = 0.15f),
0.78f to DetailBackground.copy(alpha = 0.90f),
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>,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
tabs: List<DetailTab> = emptyList(),
selectedTab: DetailTab = DetailTab.OVERVIEW,
onSelectTab: (DetailTab) -> Unit = {},
/**
* Replaces the tab strip in the band under the hero. An episode page has one thing to
* navigate the seasons so it supplies its own scroller rather than pretending to be
* a set of tabs; the frame, the fold and the focus contract stay identical either way.
* It is handed the requester to hand focus back to above it, the one to send focus to
* below it, and the callback that pins the page to the strip.
*/
strip: (@Composable (
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
) -> Unit)? = null,
eyebrow: String? = null,
subtitle: String? = null,
/**
* The heading when the item has no logo to show. An episode's page is *about* the
* episode but *headed* by the show, so it passes the series name here without it the
* fallback prints the episode's title twice, once as the heading and once under the
* number.
*/
title: String? = null,
progress: Float = 0f,
progressLabel: String? = null,
/**
* "Estimated finish: 18 August" how the viewer's own pace projects onto what is left
* of a series. It sits with the progress information rather than beside it, and is
* deliberately quiet: it is a nicety on a page whose job is Play.
*
* Its own line rather than an addition to [progressLabel], because the progress bar
* appears only for a part-watched episode and the estimate is at its most useful for
* somebody who finished one last night and has not started the next.
*/
paceLabel: String? = null,
reasons: List<String> = emptyList(),
/**
* Set only when the page was opened from the "Shows airing" row, and it takes the
* accent line the recommendation reason would otherwise have: someone who arrived by
* pressing "Thursday, 9pm" came for the schedule, not for why the engine likes the
* show. Never focusable, like the reason it replaces.
*/
airingNotice: AiringNotice? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
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
}
// The band and the pane as a whole, so a press can reach them when the one node they
// would rather land on — the selected tab, the season being watched, the first
// episode card — is not composed on this frame.
val stripEntryRequester = remember(item.id) { FocusRequester() }
val contentEntryRequester = remember(item.id) { FocusRequester() }
// Down out of the hero: the strip, and failing that the content under it. A band with
// nothing in it yet is a thing to pass through, not a thing to stop at.
val enterStripFromHero = {
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
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 - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
modifier = Modifier.fillMaxSize(),
) {
item(key = "hero") {
DetailHero(
item = item,
facts = facts,
badges = badges,
eyebrow = eyebrow,
subtitle = subtitle,
title = title ?: item.name,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
onNavigateDown = enterStripFromHero,
progress = progress,
progressLabel = progressLabel,
paceLabel = paceLabel,
reasons = reasons,
airingNotice = airingNotice,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
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") {
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
}
// The band as one focus group, so a press that cannot reach the exact
// stop it wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
}
}
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)
}
}
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes
// navigate vertically inside themselves (an episode list, its
// season chips) and override this where they do. A blanket
// handler here would take Up off every card in that list.
.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(MembySurfaceRaised.copy(alpha = 0.93f))
.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>,
eyebrow: String?,
subtitle: String?,
title: String,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean,
progress: Float,
progressLabel: String?,
paceLabel: String?,
reasons: List<String>,
airingNotice: AiringNotice?,
ratings: List<MediaRating>,
showRatingsStrip: Boolean,
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) }
// Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
// in here holds focus, the press means "take me to the band under this".
Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
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 = title,
color = Color.White,
fontSize = 38.sp,
lineHeight = 42.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// An episode's logo belongs to its *series*, so without this the page would
// name the show and never say which episode it is about. The number goes
// first and the episode's own title second: the viewer already knows the
// show they picked, and "Season 3 · Episode 4" is what they came to confirm.
if (eyebrow != null) {
Spacer(Modifier.height(10.dp))
Text(
text = eyebrow,
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (subtitle != null) {
Spacer(Modifier.height(6.dp))
Text(
text = subtitle,
color = Color.White,
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges)
if (showRatingsStrip) Spacer(Modifier.height(8.dp))
RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true)
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)
}
paceLabel?.let {
// Tighter under the bar than under the synopsis: with a bar above it this
// is the second half of one thought, without one it is a line of its own.
Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp))
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (airingNotice != null) {
Spacer(Modifier.height(10.dp))
AiringNoticeBand(airingNotice)
} else 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(),
) {
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]),
)
}
}
}
}
}
/**
* The schedule the viewer pressed, restated on the page it opened.
*
* A tinted band rather than one more accent line: it has to be findable in the half-second
* after the page appears, by someone who chose this show *because* of when it airs. It sits
* where the recommendation reason does, immediately above the actions, so nothing below the
* hero moves.
*/
@Composable
private fun AiringNoticeBand(notice: AiringNotice) {
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = Modifier
.clip(shape)
.background(DetailAccent.copy(alpha = 0.13f))
.border(1.dp, DetailAccent.copy(alpha = 0.45f), shape)
.padding(horizontal = 14.dp, vertical = 9.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notice.label,
color = DetailAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
if (notice.headline.isNotEmpty()) {
Text(
text = notice.headline,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
if (notice.detail.isNotEmpty()) {
Spacer(Modifier.height(3.dp))
Text(
text = notice.detail,
color = DetailText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Compatibility entry point for previews/tests; all rendering lives in RatingsStrip. */
@Composable
internal fun DetailMovieRatings(
ratings: List<MediaRating>,
modifier: Modifier = Modifier,
) {
RatingsStrip(ratings, visible = true, modifier = modifier)
}
@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 MembySurfaceRaised.copy(alpha = 0.70f))
.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(),
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
facts.forEachIndexed { index, value ->
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
Text(value, color = 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,
onExitUp: () -> Boolean,
onExitDown: () -> Boolean,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier
.fillMaxSize()
.focusGroup()
.onVerticalNavigation(up = onExitUp, down = onExitDown),
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
) {
// Whatever else happens, one tab carries the requester the rest of the page
// aims Up and Down at. A selected tab that is not in the list — a key
// remembered from the other kind of item, for the one frame before it is
// resolved — must not leave the band with nothing attached to land on.
val anchor = tabs.firstOrNull { it == selected } ?: tabs.firstOrNull()
tabs.forEach { tab ->
var focused by remember(tab) { mutableStateOf(false) }
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (tab == anchor) 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(MembySurfaceRaised).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) }
}
@@ -0,0 +1,255 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.EmbyOutage
import kotlinx.coroutines.delay
import kotlin.math.ceil
private val OutageRed = Color(0xFFD6403A)
private val OutageTitle = Color(0xFFFFF1F0)
private val OutageBody = Color(0xFFF2C9C6)
/**
* Slightly taller than the news bar, because unlike that one this does not go away by
* itself and has a second line to carry. Still a strip rather than a panel: it appears
* over playback too, and whatever it says it is covering somebody's film.
*/
private val BannerHeight = 60.dp
/** The same broadcast overscan inset the alert bar uses, so the two line up. */
private val SafeAreaHorizontal = 48.dp
/**
* A red bar across the top of the screen saying Emby is not answering, with the time until
* the next attempt.
*
* It exists because of one specific asymmetry: video direct-plays from Emby while
* everything else comes from the gateway, so when Emby goes quiet the film stops and
* nothing on screen explains it and the gateway is still up and able to say why. The
* counterpart alert in [ServiceAlertBanner] announces the *moment* it happened; this is
* the standing state, which is what a television switched on midway through an outage
* needs instead.
*
* Never focusable, and never dismissable. There is nothing for the viewer to do, and a bar
* that stole D-pad focus mid-browse would be worse than the outage it is reporting. It
* disappears on its own when Emby answers.
*/
@Composable
fun EmbyOutageBanner(suppressed: Boolean = false, modifier: Modifier = Modifier) {
// Collected here rather than passed in, for the reason the whole UI-conventions note
// gives: read one scope up and every poll recomposes the launcher.
val current by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle()
val outage = current?.takeUnless { suppressed }
// Hold the last value so the slide-out has something to draw.
var lastOutage by remember { mutableStateOf<EmbyOutage?>(null) }
if (outage != null) lastOutage = outage
AnimatedVisibility(
visible = outage != null,
enter = slideInVertically(tween(520, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(420)),
// Leaves faster than it arrives, and for a happier reason: Emby came back.
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(240)),
// Above the news bar (8f) — an outage outranks an announcement — and below the
// mandatory update screen, which owns the whole display.
modifier = modifier.zIndex(9f),
) {
lastOutage?.let { OutageBanner(it) }
}
}
/**
* Internal so previews and the screenshot test can render the bar without the drop-in
* wrapper, whose entire job is an animation a still frame says nothing about.
*/
@Composable
internal fun OutageBanner(outage: EmbyOutage, nowMillis: () -> Long = { elapsedRealtime() }) {
// One tick a second, not a frame clock: this is a number that changes once a second
// and animating it would recompose the bar sixty times for every time it changes.
val remaining by produceState(
initialValue = secondsUntil(outage.nextAttemptAtMillis, nowMillis()),
outage.nextAttemptAtMillis,
) {
while (true) {
value = secondsUntil(outage.nextAttemptAtMillis, nowMillis())
delay(1_000L)
}
}
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(BannerHeight)
.background(
// Red, and deepest under the text. Over a paused film this bar has to
// supply its own contrast rather than borrow the picture's.
Brush.horizontalGradient(
0f to Color(0xFF7A1512),
0.55f to Color(0xF08E1A16),
1f to Color(0xD9601210),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
OutagePulse()
Spacer(Modifier.width(16.dp))
Box(Modifier.width(3.dp).height(30.dp).background(OutageRed))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
"Emby Server failed to respond",
color = OutageTitle,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
"Memby failed to connect to the Emby instance. Playback unavailable.",
color = OutageTitle,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(16.dp))
// Read directly rather than through an animated float: this changes once a
// second, so the recomposition it costs is one per second of a 60dp bar.
Text(
retryLabel(remaining),
color = OutageBody,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
// The same rule-and-fade the news bar ends on, so the two read as one system.
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
Brush.horizontalGradient(
listOf(OutageRed, OutageRed.copy(alpha = 0.35f), Color.Transparent),
),
),
)
Box(
Modifier
.fillMaxWidth()
.height(10.dp)
.background(Brush.verticalGradient(listOf(Color(0x77000000), Color.Transparent))),
)
}
}
/**
* A slow pulse in place of the Emby mark the news bar shows. It says "still trying"
* without a spinner, which on a television reads as the app being busy rather than the
* server being away.
*
* The animated value is read inside the [Canvas] lambda and never in the composable body
* read it here and this whole bar would recompose every frame, permanently, for as long as
* the outage lasts. That is the one thing this app cannot afford on a weak TV box.
*/
@Composable
private fun OutagePulse() {
val transition = rememberInfiniteTransition(label = "outage-pulse")
val pulse = transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1_400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "outage-pulse-alpha",
)
Canvas(Modifier.size(14.dp)) {
drawCircle(color = OutageRed.copy(alpha = pulse.value), radius = size.minDimension / 2f)
}
}
/** Whole seconds until the next attempt, never negative. */
internal fun secondsUntil(deadlineMillis: Long, nowMillis: Long): Int =
ceil((deadlineMillis - nowMillis).coerceAtLeast(0L) / 1000.0).toInt()
/**
* The countdown's wording. It says what is about to happen rather than counting for its
* own sake the only useful thing a viewer can take from this bar is that something is
* still trying and roughly when.
*/
internal fun retryLabel(secondsRemaining: Int): String = when {
secondsRemaining <= 0 -> "Retrying now…"
secondsRemaining == 1 -> "Retrying in 1s"
else -> "Retrying in ${secondsRemaining}s"
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
@TvPreview
@Composable
private fun EmbyOutageBannerPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 42_000L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
/** The last second before the next attempt, which is a different string. */
@TvPreview
@Composable
private fun EmbyOutageBannerRetryingPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 0L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
@@ -0,0 +1,530 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Tv
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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 com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.SeasonMarker
import com.ponzischeme89.memby.ui.detail.SeasonProgress
import com.ponzischeme89.memby.ui.detail.episodeAfter
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
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.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import kotlinx.coroutines.delay
/**
* A single episode, opened from Continue Watching or from a show's episode list.
*
* It is the same page as a movie's and a series' [DetailPageScaffold], the same hero, the
* same fold with the two substitutions an episode needs. The logo belongs to the *series*,
* so the season and episode number go directly under it with the episode's own title
* beneath. And where a movie has tabs, this has the season scroller: the one thing worth
* navigating from an episode is the rest of the show, and it doubles as the answer to "where
* am I up to", which is the question somebody resuming a series actually has.
*/
@Composable
fun EpisodeDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings)
var episodes by remember(item.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
// Keyed on the series, not the episode: walking from one episode's page to the next
// within the same show must not re-fetch the whole catalogue of it.
LaunchedEffect(item.seriesId) {
val seriesId = item.seriesId
if (seriesId.isNullOrBlank()) {
episodes = emptyList()
return@LaunchedEffect
}
runCatching { repository.getSeriesEpisodes(seriesId) }
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
.onFailure {
loadFailed = true
episodes = emptyList()
}
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
EpisodeDetailContent(
item = item,
episodes = episodes,
loadFailed = loadFailed,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
modifier = modifier,
)
}
/** [episodes] is null while the series' episode list is still coming. */
@Composable
internal fun EpisodeDetailContent(
item: BaseItem,
episodes: List<BaseItem>?,
loadFailed: Boolean,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
modifier: Modifier = Modifier,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
onOpenItem: (BaseItem) -> Unit = {},
) {
val currentSeason = item.parentIndexNumber
val markers = remember(episodes, currentSeason) {
seasonMarkers(episodes.orEmpty(), currentSeason)
}
// The scroller opens on the season this episode is in and stays wherever the viewer
// moves it. Selecting a season browses; it never changes which episode the page is about.
var selectedSeason by remember(item.id) { mutableStateOf(currentSeason) }
LaunchedEffect(markers) {
if (markers.none { it.season == selectedSeason }) {
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
}
}
val seasonEpisodes = remember(episodes, selectedSeason) {
episodesForSeason(episodes.orEmpty(), selectedSeason)
}
val upNext = remember(episodes, item.id) { episodeAfter(episodes.orEmpty(), item) }
val seriesProgress = remember(markers, currentSeason) {
seriesProgressLabel(markers, currentSeason)
}
val play = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val emptyPane = remember(item.id) { FocusRequester() }
val seasonRequesters = remember(markers) { markers.associate { it.season to FocusRequester() } }
val episodeListState = rememberLazyListState()
val seasonListState = rememberLazyListState()
// Every focus target has to be attached on the frame the press lands. While the episode
// request is in flight there are no cards and no chips, so both directions resolve to
// whatever is actually placed and the rest falls back to ordinary focus search.
val hasEpisodeCards = seasonEpisodes.isNotEmpty()
val contentEntry = if (hasEpisodeCards) firstEpisode else emptyPane
// The strip's entry point is the selected season's own stop, not a requester of the
// scaffold's — until the episode list lands there is no stop to land on, and Down from
// Play has to fall back to ordinary focus search rather than throw.
val selectedSeasonChip = seasonRequesters[selectedSeason]
val stripEntry = selectedSeasonChip ?: FocusRequester.Default
LaunchedEffect(markers, selectedSeason) {
val index = markers.indexOfFirst { it.season == selectedSeason }
if (index >= 0) runCatching { seasonListState.scrollToItem(index) }
}
// Open the list on the episode the page is about rather than at the top of the season.
// Episode 9 of a twelve-part season is otherwise nine presses below the fold, on the
// one screen where the viewer has already said which episode they mean.
LaunchedEffect(seasonEpisodes, item.id) {
val index = seasonEpisodes.indexOfFirst { it.id == item.id }
if (index > 0) runCatching { episodeListState.scrollToItem(index) }
}
RestoreDetailFocus(
itemId = item.id,
zone = DetailZone.PLAY,
play = play,
tabStrip = stripEntry,
related = firstEpisode,
relatedReady = hasEpisodeCards,
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 = mediaBadges(item),
eyebrow = episodeEyebrow(item),
subtitle = item.name.takeIf(String::isNotBlank),
title = item.seriesName?.takeIf(String::isNotBlank) ?: item.name,
playLabel = primaryActionLabel(item),
onPlay = { onPlay(item) },
playFocusRequester = play,
tabFocusRequester = stripEntry,
contentFocusRequester = contentEntry,
modifier = modifier,
progress = playbackProgress(item),
progressLabel = remainingLabel(item),
// An episode inherits no explanation of its own; what the page can say about what
// follows is more use than a taste the engine learned about the show.
reasons = listOfNotNull(upNext?.let { "Up next ${episodeHeadline(it)}" }),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
strip = { _, _, onFocused ->
SeasonScroller(
markers = markers,
selectedSeason = selectedSeason,
currentSeason = currentSeason,
seriesProgress = seriesProgress,
selectedFocusRequester = selectedSeasonChip,
seasonFocusRequesters = seasonRequesters,
listState = seasonListState,
onSelect = { selectedSeason = it },
onFocused = onFocused,
)
},
heroActions = buildList {
add(
DetailHeroAction(
// A heart, not a tick: "Mark watched" beside it is a tick as well. The
// heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
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"
},
),
)
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) },
),
)
// The show this episode belongs to, so the full series page is one press away
// rather than a walk back through Continue Watching.
item.seriesId?.takeIf(String::isNotBlank)?.let { seriesId ->
add(
DetailHeroAction(
icon = Icons.Default.Tv,
description = "Open ${item.seriesName ?: "the series"}",
onClick = {
onOpenItem(
BaseItem(
id = seriesId,
name = item.seriesName.orEmpty(),
type = "Series",
),
)
},
),
)
}
},
) {
EpisodeSeasonPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
currentEpisodeId = item.id,
loadFailed = loadFailed,
firstEpisodeFocusRequester = firstEpisode,
emptyFocusRequester = emptyPane,
aboveEpisodes = stripEntry,
listState = episodeListState,
onPlay = onPlay,
)
}
}
/**
* The band under the hero: every season the library holds, with the viewer's place in the
* show marked on it.
*
* A season behind the one being watched is dimmed and ticked. That is the whole point of
* the component it answers "where am I up to" at a glance, which is what somebody
* resuming a long show is actually asking, and it does it without a tab strip's promise
* that each stop is a different *kind* of content.
*/
@Composable
private fun SeasonScroller(
markers: List<SeasonMarker>,
selectedSeason: Int?,
currentSeason: Int?,
seriesProgress: String?,
selectedFocusRequester: FocusRequester?,
seasonFocusRequesters: Map<Int, FocusRequester>,
listState: LazyListState,
onSelect: (Int) -> Unit,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.Bottom,
) {
if (markers.isEmpty()) {
Text(
text = "Loading seasons…",
color = DetailQuietText,
fontSize = 13.sp,
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
)
} else {
LazyRow(
state = listState,
// Up and Down out of the band belong to the scaffold, which knows
// where to fall back to when the exact stop it wants is not composed
// — a season chip scrolled out of this row is an ordinary state.
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
items(markers, key = { it.season }) { marker ->
SeasonStop(
marker = marker,
selected = marker.season == selectedSeason,
watching = marker.season == currentSeason,
focusRequester = if (marker.season == selectedSeason) {
selectedFocusRequester
} else {
seasonFocusRequesters[marker.season]
},
onSelect = { onSelect(marker.season) },
onFocused = onFocused,
)
}
}
}
seriesProgress?.let {
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 18.dp, bottom = 15.dp),
)
}
// Same caption on the Down key the tab strip carries; never focusable.
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
)
}
}
}
@Composable
private fun SeasonStop(
marker: SeasonMarker,
selected: Boolean,
watching: Boolean,
focusRequester: FocusRequester?,
onSelect: () -> Unit,
onFocused: () -> Unit,
) {
var focused by remember(marker.season) { mutableStateOf(false) }
val done = marker.progress == SeasonProgress.WATCHED
val labelColour = when {
selected || focused -> Color.White
// Behind the viewer: stated, not hidden. The tick beside it is what says why it
// is quieter than the rest, or a dimmed season reads as one that failed to load.
done -> DetailQuietText.copy(alpha = 0.55f)
else -> DetailQuietText
}
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.testTag("season-stop-${marker.season}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) { onSelect(); onFocused() }
}
.clickable(onClick = onSelect),
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
if (done) {
Icon(
Icons.Default.Check,
contentDescription = "Watched",
tint = if (selected || focused) DetailAccent else DetailAccent.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).padding(end = 1.dp),
)
Spacer(Modifier.width(5.dp))
}
Text(
text = marker.label,
color = labelColour,
fontSize = 15.sp,
fontWeight = if (selected || focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
)
if (watching) {
Spacer(Modifier.width(7.dp))
Text(
text = "WATCHING",
color = DetailAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
maxLines = 1,
modifier = Modifier
.background(DetailAccent.copy(alpha = 0.16f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
seasonProgressLabel(marker)?.let {
Text(
text = it,
color = if (done) DetailQuietText.copy(alpha = 0.5f) else DetailQuietText,
fontSize = 10.sp,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 11.dp),
)
}
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected || focused) DetailAccent else Color.Transparent,
),
)
}
}
/**
* The pane under the scroller: the selected season's episodes, this one flagged.
*
* Internal so a screenshot test can render it at [detailPaneHeight] on its own it lives
* below the fold, where a capture of the whole page shows nothing but its top edge.
*/
@Composable
internal fun EpisodeSeasonPane(
episodes: List<BaseItem>?,
seasonEpisodes: List<BaseItem>,
currentEpisodeId: String,
loadFailed: Boolean,
firstEpisodeFocusRequester: FocusRequester,
emptyFocusRequester: FocusRequester,
aboveEpisodes: FocusRequester,
listState: LazyListState,
onPlay: (BaseItem) -> Unit,
) {
when {
episodes == null -> DetailFocusablePane(emptyFocusRequester) {
Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp)
}
loadFailed -> DetailFocusablePane(emptyFocusRequester) {
Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp)
}
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) {
Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp)
}
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
EpisodeCard(
episode = episode,
onClick = { onPlay(episode) },
seasonFocusRequester = aboveEpisodes,
isFirst = index == 0,
isCurrent = episode.id == currentEpisodeId,
modifier = if (index == 0) {
Modifier.focusRequester(firstEpisodeFocusRequester)
} else {
Modifier
},
)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
package com.ponzischeme89.memby.ui
internal enum class HomeGreetingPeriod(val words: String) {
MORNING("Good morning"),
AFTERNOON("Good afternoon"),
EVENING("Good evening"),
}
internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hourOfDay) {
in 5..11 -> HomeGreetingPeriod.MORNING
in 12..16 -> HomeGreetingPeriod.AFTERNOON
else -> HomeGreetingPeriod.EVENING
}
/** The greeting lives between leaving the hero and leaving Continue Watching. */
internal fun shouldShowHomeGreeting(
hasHero: Boolean,
focusedRowId: String?,
rowIds: List<String>,
): Boolean {
if (!hasHero || focusedRowId == null) return false
val focusedIndex = rowIds.indexOf(focusedRowId)
val continueIndex = rowIds.indexOf("continue")
return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex
}
@@ -0,0 +1,598 @@
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.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.floorMod
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import java.util.TimeZone
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
/**
* The current local day, re-read when the clock passes midnight.
*
* A television is not a phone: it is quite normal for this launcher to still be on screen
* at one in the morning, or to be woken from the screensaver into a composition that
* started yesterday. Reading the day once at composition would mean the "new releases" a
* household sees are the ones that were new whenever they last cold-started the app. The
* timer sleeps until the next local midnight rather than polling, so the cost of this is
* one suspended coroutine.
*/
@Composable
internal fun rememberHomeHeroDay(): Long {
var day by remember { mutableStateOf(currentLocalDay()) }
LaunchedEffect(Unit) {
while (true) {
val now = System.currentTimeMillis()
// A floor keeps a clock correction that lands exactly on the boundary from
// turning this into a busy loop.
delay(
millisUntilNextLocalDay(now, TimeZone.getDefault().getOffset(now))
.coerceAtLeast(MIN_DAY_TICK_MS),
)
day = currentLocalDay()
}
}
return day
}
private fun currentLocalDay(): Long = System.currentTimeMillis().let { now ->
localEpochDay(now, TimeZone.getDefault().getOffset(now))
}
private const val MIN_DAY_TICK_MS = 1_000L
/**
* The movie feature owns the header until a shelf below it is being browsed.
*
* Scroll position alone was not enough. Continue Watching is the first row, so moving down
* into it scrolls nothing the hero stayed while the viewer walked along a row whose cards
* had nowhere to describe themselves, which is the one thing the metadata panel exists for.
* [rowFocused] is what stands the hero down, and pressing Up out of the first row is what
* brings it back; the scroll test remains because a viewer who is somewhere down the
* launcher should not have the hero returned to them by a row losing focus.
*/
internal fun shouldShowHomeMovieHero(
hasMovies: Boolean,
listAtTop: Boolean,
rowFocused: Boolean = false,
): Boolean = hasMovies && listAtTop && !rowFocused
/**
* A hero card, the caption it wears and when the gateway composed it the one line
* saying why it is there.
*
* The label used to be the card's *position* `listOf("POPULAR", "NEW RELEASE",
* "TRENDING")[index]` while the selection below interleaves two sources and then falls
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
* one POPULAR. A caption that can be wrong is worse than no caption.
*/
internal data class HomeHeroPick(
val item: BaseItem,
val label: String,
val reason: String? = null,
)
private const val LABEL_NEW = "NEW RELEASE"
private const val LABEL_POPULAR = "POPULAR"
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
/** The `kind` of the server-composed hero row. It is consumed here, never drawn as a row. */
internal const val SERVER_HERO_ROW_KIND = "hero"
/**
* The hero the gateway composed, if it sent one.
*
* This is the preferred path and it is deliberately a *verbatim* read: the server has
* Radarr's digital release dates, Sonarr's premieres and the stored review scores, none of
* which reach the television, so second-guessing its order here would only ever be able to
* throw that evidence away. No day rotation either the underlying facts already change
* daily, and rotating a merit ranking is precisely how the best-reviewed release of the
* week ends up in the fourth slot.
*
* Each card carries its own caption, so a kind of hero the server invents tomorrow reads
* correctly on this build. A card that somehow arrives without one falls back to the
* neutral library caption rather than to a claim nobody made.
*/
internal fun serverHeroPicks(rows: List<HomeRow>): List<HomeHeroPick> =
rows.asSequence()
.filter { it.kind == SERVER_HERO_ROW_KIND }
.flatMap { it.items.asSequence() }
.filter { it.membyPlayable }
.distinctBy(BaseItem::id)
.take(4)
.map { item ->
HomeHeroPick(
item = item,
label = item.membyHeroLabel?.takeIf(String::isNotBlank) ?: LABEL_LIBRARY,
reason = item.membyHeroReason?.takeIf(String::isNotBlank),
)
}
.toList()
/**
* Picks a deliberate mix of fresh and popular movies while preserving server ranking.
*
* This is the **direct path's** rule, and the fallback whenever the gateway sends no hero
* row a container that is down, or one older than the feature. It ranks on the only
* evidence a television has, which is which shelf a title was drawn from; where the
* gateway is answering, [serverHeroPicks] wins because it can see rather more than that.
*
* [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
* each candidate list, so a household that leaves the launcher on the same four films for
* a fortnight instead sees a different set every morning. It is a rotation rather than a
* shuffle on purpose: the server's ranking is still the order, so the titles it thinks are
* worth leading with keep coming round, and yesterday's hero is merely one place further
* down rather than somewhere unpredictable. Passing the same day twice always produces the
* same four cards, which is what keeps the launcher from reshuffling under a viewer who
* simply walked back into the room.
*/
internal fun selectHomeHeroMovies(
rows: List<HomeBrowseRow>,
day: Long = 0L,
serverRows: List<HomeRow> = emptyList(),
): List<HomeHeroPick> {
serverHeroPicks(serverRows).takeIf(List<HomeHeroPick>::isNotEmpty)?.let { return it }
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
}
val newReleases = rows
.filter { it.matches("latest", "recent", "new release", "just added") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
.rotatedBy(day)
val popular = rows
.filter { it.matches("popular", "trending", "recommended", "top pick") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
.rotatedBy(day)
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).rotatedBy(day)
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)
}
/**
* Moves the start of the list on by [by] places, wrapping. A negative day is as valid as a
* positive one a television whose clock has not yet been set can report an instant before
* the epoch, and the launcher must still draw four cards rather than throw.
*/
private fun <T> List<T>.rotatedBy(by: Long): List<T> {
if (size <= 1) return this
val offset = floorMod(by, size.toLong()).toInt()
if (offset == 0) return this
return subList(offset, size) + subList(0, offset)
}
@Composable
internal fun HomeMovieHero(
movies: List<HomeHeroPick>,
navigationFocusRequester: FocusRequester,
contentEntryFocusRequester: FocusRequester? = null,
returnFocusItemId: String? = null,
returnFocusRequester: FocusRequester? = null,
downFocusRequester: 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()
// Down is stated rather than left to Compose's spatial search. The hero cards
// are far wider than the cards below them, so the nearest-centre rule reaches
// past the first card of the shelf — Continue Watching would open on the second
// title when the whole point of that row is the first.
var featuredModifier: Modifier = Modifier
.weight(1f)
.fillMaxHeight()
.focusProperties {
left = navigationFocusRequester
if (downFocusRequester != null) down = downFocusRequester
}
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),
) {
val minis = movies.drop(1).take(3)
minis.forEachIndexed { index, pick ->
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
// Only the bottom mini leaves the hero by Down; the ones above it are
// still walking their own column.
if (downFocusRequester != null && index == minis.lastIndex) {
miniModifier = miniModifier.focusProperties { down = downFocusRequester }
}
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.
*
* The gateway's captions are matched here as strings rather than as an enum for the same
* reason their wording lives on the server: a caption this build has never heard of gets
* the neutral wash and reads correctly, where a `when` over a sealed type would have to be
* taught every new one in an app release.
*/
private fun labelTint(label: String): Color = when (label) {
LABEL_NEW -> Color(0x667253B7)
LABEL_POPULAR -> Color(0x66499BD5)
// A premiere is a different kind of news from a film, and reads as one.
"SERIES PREMIERE", "NEW SEASON" -> Color(0x664F9E7A)
"HIGHLY RATED" -> Color(0x66B8873F)
else -> Color(0x66C67A42)
}
@Composable
private fun FeaturedMovieCard(
pick: HomeHeroPick,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier,
previewArtwork: ImageBitmap?,
) {
val item = pick.item
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
// Not "Featured movie": a series premiere can lead, and a screen reader announcing
// one as a movie is worse than one announcing it by the caption it is wearing.
contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to MembySurface.copy(alpha = 0.95f),
0.48f to MembySurface.copy(alpha = 0.66f),
1f to MembySurface.copy(alpha = 0.09f),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color.Transparent,
0.72f to Color.Transparent,
1f to Color(0xD9000000),
),
),
)
// Play is measured before the words, and that is the whole layout.
//
// This column is the height of a fixed-height card. A Column hands each child
// the height left after the ones before it, so the chip — being last — was
// given whatever a two-line title had not already taken, and rendered as a
// green sliver with its label squeezed out of it. Not clipped: *compressed*,
// which is why it looked malformed rather than missing.
//
// Putting the text in a `weight(1f, fill = false)` child inverts the order:
// weighted children are measured from what is left over, so the spacer and the
// chip take their natural size first and the prose is what gives way. The
// synopsis still stands down on its own when the title wraps, so in practice
// nothing has to be cut at all — but the button can no longer be the thing that
// pays for a long title, whatever the artwork, the ratings strip or the
// viewport do.
var titleLines by remember(item.id) { mutableIntStateOf(1) }
Column(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(0.58f)
.fillMaxHeight()
.padding(22.dp),
verticalArrangement = Arrangement.Center,
) {
Column(Modifier.weight(1f, fill = false)) {
// No eyebrow on the featured card. "NEW RELEASE" over a fact line that
// already prints the year was a whole line of the card's height spent
// on the least specific thing on it — and it is the line that pushed a
// wrapped title into the button. The three minis beside it keep theirs:
// they have no fact line, and there the label is the only reason given.
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)
// The reason takes the synopsis's place rather than adding a line to
// it. It is why this card is leading — "Well reviewed, released
// yesterday" — which is more use here than the first two lines of a
// plot the detail page prints in full, and one line where the synopsis
// is two, so preferring it can only make the card shorter. There is
// still no eyebrow above the title: that was the line that pushed a
// wrapped title into the button.
//
// It sits *above* the ratings strip, and that order is load-bearing.
// This column is the one that gives way when a title wraps onto two
// lines, and whatever is last in it is what gets cut — with the reason
// below the strip, the one line explaining why this card is leading
// the launcher was silently dropped on exactly the long-titled films
// most likely to be leading it. The scores are also on the detail page
// this card opens; the reason is not anywhere else.
val reason = pick.reason?.takeIf(String::isNotBlank)
if (reason != null) {
Spacer(Modifier.height(7.dp))
Text(
reason,
color = MembyAccent,
fontSize = 13.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
)
if (reason == null && 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 and certificate the same wording and order 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". Scores are not here: they belong
* to the ratings strip, which names the provider behind each one.
*/
@Composable
private fun HeroFactLine(item: BaseItem) {
val facts = heroFacts(item)
if (facts.isEmpty()) return
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@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(MembySurfaceRaised)) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to MembySurface.copy(alpha = 0.91f),
0.78f to MembySurface.copy(alpha = 0.52f),
1f to MembySurface.copy(alpha = 0.19f),
),
),
)
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)),
),
),
)
}
}
@@ -5,11 +5,11 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.analytics.RowAnalytics 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
@@ -17,18 +17,26 @@ 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
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
enum class HomeSection { CONTINUE, NEXT_UP, FAVORITES, LATEST } enum class HomeSection { CONTINUE, FAVORITES, LATEST }
data class HomeUiState( data class HomeUiState(
/**
* Everything in progress, including the episode that follows one just finished:
* Continue Watching and Next Up are one row. The merge happens on the gateway, or in
* `EmbyRepository.getContinueWatching` on the direct path.
*/
val continueWatching: List<BaseItem> = emptyList(), val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(), val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(), val latestMovies: List<BaseItem> = emptyList(),
/** /**
@@ -49,18 +57,28 @@ 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> /**
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,
favorites = favorites, favorites = favorites,
latestMovies = latestMovies, latestMovies = latestMovies,
rows = rows, rows = rows,
@@ -68,14 +86,16 @@ data class HomeUiState(
companion object { companion object {
fun from(cache: HomeCache?) = HomeUiState( fun from(cache: HomeCache?) = HomeUiState(
continueWatching = cache?.continueWatching.orEmpty(), // A cache written before the two rows merged still carries its Next Up items
nextUp = cache?.nextUp.orEmpty(), // separately; folding them in keeps the cold-start row complete until the
// first refresh replaces it with a properly interleaved one.
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
.distinctBy(BaseItem::id),
favorites = cache?.favorites.orEmpty(), favorites = cache?.favorites.orEmpty(),
latestMovies = cache?.latestMovies.orEmpty(), latestMovies = cache?.latestMovies.orEmpty(),
rows = cache?.rows.orEmpty(), rows = cache?.rows.orEmpty(),
loading = buildSet { loading = buildSet {
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE) if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
if (cache?.nextUp.isNullOrEmpty()) add(HomeSection.NEXT_UP)
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES) if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST) if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
}, },
@@ -83,12 +103,57 @@ data class HomeUiState(
} }
} }
/**
* The connection-health slice of [HomeUiState]: what the banner and the maintenance
* screen need, and nothing that would drag the rows into a recomposition with it.
*/
data class HomeStatus(
val hasRefreshError: Boolean = false,
val statusMessage: String? = null,
val maintenanceMessage: String? = null,
)
data class ForYouUiState(
val rows: List<HomeRow> = emptyList(),
val availableMinutes: Int = 0,
val loading: Boolean = false,
val error: String? = null,
)
class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { 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()
// Genre pages own paged copies that do not live in HomeUiState. This small mutation
// stream lets those copies reflect a heart press immediately, including a rollback
// when the server rejects it, without refreshing or losing the active category.
private val _favoriteChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
private val _playedChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val playedChanges: StateFlow<Map<String, Boolean>> = _playedChanges.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null private var metadataJob: Job? = null
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) { private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
@@ -97,12 +162,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 { viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() } repository.playbackStops.collect { refreshWatching() }
} }
@@ -116,29 +177,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)
@@ -155,16 +195,43 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.reportRowEvents(analytics.drain()) repository.reportRowEvents(analytics.drain())
} }
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
val minutes = availableMinutes.coerceIn(0, 360)
_focusedItem.value = null
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) }
.onSuccess { rows ->
_forYou.value = ForYouUiState(
rows = rows,
availableMinutes = minutes,
loading = false,
)
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
}
.onFailure {
_forYou.update {
it.copy(
loading = false,
error = "For You is temporarily unavailable",
)
}
}
}
}
fun refreshAll() { fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock { refreshMutex.withLock {
// A refresh can make a newly imported episode the next playable item for
// an existing series ID. Never launch the pre-refresh negotiated session.
repository.invalidatePlaybackPrefetch()
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
if (repository.supportsBatchHome) { if (repository.supportsBatchHome) {
loadBatchHome() loadBatchHome()
} else { } else {
coroutineScope { coroutineScope {
launch { loadContinueWatching() } launch { loadContinueWatching() }
launch { loadNextUp() }
launch { loadFavorites() } launch { loadFavorites() }
launch { loadLatest() } launch { loadLatest() }
} }
@@ -181,18 +248,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun loadBatchHome() { private suspend fun loadBatchHome() {
runCatching { repository.getHome() } runCatching { repository.getHome() }
.onSuccess { home -> .onSuccess { home ->
val taggedHome = home.withAiringTodayTags()
_state.update { current -> _state.update { current ->
current.copy( current.copy(
continueWatching = home.continueWatching, continueWatching = taggedHome.continueWatching,
nextUp = home.nextUp, favorites = taggedHome.favorites,
favorites = home.favorites, latestMovies = taggedHome.latestMovies,
latestMovies = home.latestMovies,
// Recommendation rows are built in the background by the gateway, // Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows // so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in. // we already had stops the strip flickering out and back in.
rows = home.rows.ifEmpty { current.rows }, rows = taggedHome.rows.ifEmpty { current.rows },
loading = emptySet(), loading = emptySet(),
hasRefreshError = home.partial, hasRefreshError = taggedHome.partial,
statusMessage = null, statusMessage = null,
// A successful response is the only thing that clears the // A successful response is the only thing that clears the
// maintenance screen, so a retry that fails keeps it up. // maintenance screen, so a retry that fails keeps it up.
@@ -202,6 +269,10 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) { if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem) initialFocusedItem(_state.value)?.let(::focusItem)
} }
// Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows()
} }
.onFailure { error -> .onFailure { error ->
val maintenance = isMaintenanceError(error) val maintenance = isMaintenanceError(error)
@@ -216,25 +287,107 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
} }
private suspend fun refreshRecommendationRows() {
val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return
_state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" ||
row.id.startsWith("similar:") ||
row.id.startsWith("curated:")
}
state.copy(rows = fixedRows + taggedFresh)
}
}
/** /**
* Updates local metadata immediately, then enriches it only after focus settles. * Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation. * Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
*/ */
fun focusItem(item: BaseItem) { fun focusItem(item: BaseItem) {
val cached = synchronized(metadataCache) { metadataCache[item.id] } val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item val focused = (cached ?: item).copy(
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
membyRecommendationReason = item.membyRecommendationReason
?: cached?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
)
_focusedItem.value = focused
metadataJob?.cancel() metadataJob?.cancel()
if (cached != null) return
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch coroutineScope {
synchronized(metadataCache) { metadataCache[item.id] = details } // Resolution is tiny compared with video buffering and makes the later
if (_focusedItem.value?.id == item.id) { // Play click a memory lookup. The repository single-flights requests,
_focusedItem.value = details // so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
}
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
launch { runCatching { repository.getRelated(focused) } }
}
if (cached == null && !item.isSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
val taggedDetails = details.copy(
membyAiringToday = focused.membyAiringToday,
membyRecommendationReason = focused.membyRecommendationReason,
membyCompatibility = focused.membyCompatibility,
)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
}
}
}
launch { warmDetailPage(item) }
} }
} }
} }
/**
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens the item
* record, its "why you might enjoy it" and its playable URL are all warmed above but
* the episode list and the trailer were not, so a series page opened with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
* episode, and all of them want the same show's list.
*
* **It waits longer than the metadata warm does**, and that is the whole cost control.
* An episode list is the largest request the client makes a long-running show is a
* thousand records so warming one per card as somebody scans across a shelf would
* spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves,
* so a viewer travelling along a row never reaches it; one who has stopped on a card,
* which is what precedes a press, does. Both requests are single-flighted and cached on
* the repository, so the press that follows finds the answer rather than a second copy
* of the request.
*/
private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// A series is keyed on itself, an episode on the show it belongs to — which is
// exactly what its own detail page will ask for.
val seriesId = when {
item.isSeries -> item.id
item.isEpisode -> item.seriesId
else -> null
}
if (!seriesId.isNullOrBlank()) {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
}
launch { runCatching { repository.getLocalTrailer(item.id) } }
}
}
fun setFavorite(item: BaseItem, favorite: Boolean) { fun setFavorite(item: BaseItem, favorite: Boolean) {
updateFavorite(item, favorite) updateFavorite(item, favorite)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -249,6 +402,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
private fun updateFavorite(item: BaseItem, favorite: Boolean) { private fun updateFavorite(item: BaseItem, favorite: Boolean) {
_favoriteChanges.update { it + (item.id to favorite) }
updateUserData(item.id) { it.copy(isFavorite = favorite) } updateUserData(item.id) { it.copy(isFavorite = favorite) }
_state.update { state -> _state.update { state ->
val updatedItem = item.copy( val updatedItem = item.copy(
@@ -265,6 +419,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
fun setPlayed(item: BaseItem, played: Boolean) { fun setPlayed(item: BaseItem, played: Boolean) {
val previousPlayed = item.userData?.played == true
val previousPosition = item.userData?.playbackPositionTicks ?: 0L
_playedChanges.update { it + (item.id to played) }
updateUserData(item.id) { updateUserData(item.id) {
it.copy( it.copy(
played = played, played = played,
@@ -274,10 +431,44 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setPlayed(item.id, played) } runCatching { repository.setPlayed(item.id, played) }
.onSuccess { confirmed -> .onSuccess { confirmed ->
updateUserData(item.id) { it.copy(played = confirmed) } _playedChanges.update { it + (item.id to confirmed) }
updateUserData(item.id) {
it.copy(
played = confirmed,
playbackPositionTicks = if (confirmed) 0L else it.playbackPositionTicks,
)
}
} }
.onFailure { .onFailure {
updateUserData(item.id) { it.copy(played = !played) } _playedChanges.update { it + (item.id to previousPlayed) }
updateUserData(item.id) {
it.copy(played = previousPlayed, playbackPositionTicks = previousPosition)
}
}
}
}
fun removeFromContinueWatching(item: BaseItem) {
val previous = _state.value
_state.update { state ->
state.copy(
continueWatching = state.continueWatching.filterNot { it.id == item.id },
rows = state.rows.map { row ->
if (row.kind == "continue" || row.kind == "nextup") {
row.copy(items = row.items.filterNot { it.id == item.id })
} else {
row
}
},
)
}
_focusedItem.update { focused -> focused?.takeUnless { it.id == item.id } }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.removeFromContinueWatching(item.id) }
.onSuccess { persistCurrentHome() }
.onFailure {
_state.value = previous
_focusedItem.value = item
} }
} }
} }
@@ -289,7 +480,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { _state.update {
it.copy( it.copy(
continueWatching = it.continueWatching.map(BaseItem::updated), continueWatching = it.continueWatching.map(BaseItem::updated),
nextUp = it.nextUp.map(BaseItem::updated),
favorites = it.favorites.map(BaseItem::updated), favorites = it.favorites.map(BaseItem::updated),
latestMovies = it.latestMovies.map(BaseItem::updated), latestMovies = it.latestMovies.map(BaseItem::updated),
// Server rows hold their own copies of the same items, so an optimistic // Server rows hold their own copies of the same items, so an optimistic
@@ -306,18 +496,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun refreshWatching() { private suspend fun refreshWatching() {
refreshMutex.withLock { refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) } _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
if (repository.supportsBatchHome) { if (repository.supportsBatchHome) {
// One request is cheaper than two here as well, and playback just // Playback just invalidated this user's rows on the gateway anyway.
// invalidated this user's rows on the gateway anyway.
loadBatchHome() loadBatchHome()
} else { } else {
coroutineScope { loadContinueWatching(clearLoading = false)
launch { loadContinueWatching(clearLoading = false) }
launch { loadNextUp(clearLoading = false) }
}
} }
_state.update { it.copy(loading = it.loading - setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) } _state.update { it.copy(loading = it.loading - HomeSection.CONTINUE) }
persistCurrentHome() persistCurrentHome()
} }
} }
@@ -327,11 +513,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
state.copy(continueWatching = items) state.copy(continueWatching = items)
} }
private suspend fun loadNextUp(clearLoading: Boolean = true) =
load(HomeSection.NEXT_UP, clearLoading, { repository.getNextUp() }) { state, items ->
state.copy(nextUp = items)
}
private suspend fun loadFavorites() = private suspend fun loadFavorites() =
load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items -> load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
state.copy(favorites = items) state.copy(favorites = items)
@@ -380,15 +561,77 @@ 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
/**
* How long focus must rest on a card before its detail page is warmed, measured
* from the press that focused it. Deliberately well past
* [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
* what the panel beside the row says, so it should follow the D-pad closely, while
* this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage].
*/
private const val DETAIL_PREFETCH_DELAY_MS = 450L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? = private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull() state.continueWatching.firstOrNull()
?: state.latestMovies.firstOrNull() ?: state.latestMovies.firstOrNull()
?: state.favorites.firstOrNull() ?: state.favorites.firstOrNull()
} }
} }
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
val taggedContinue = continueWatching
.withAiringTodayItemTags(airingTodayKeys)
.prioritizeAiringToday()
return copy(
rows = rows.withAiringTodayRowTags(airingTodayKeys).map { row ->
if (row.id == "continue") {
row.copy(items = row.items.prioritizeAiringToday())
} else {
row
}
},
continueWatching = taggedContinue,
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
)
}
private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
firstOrNull { it.id == "sonarr-airing-today" }
?.items
.orEmpty()
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
.filterTo(mutableSetOf(), String::isNotEmpty)
private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRow> =
map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) }
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
val showName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
}
if (!item.isTvSchedule && showName != null && showName.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
}
}
/** Stable partition: today's shows move forward without disturbing recency within groups. */
private fun List<BaseItem>.prioritizeAiringToday(): List<BaseItem> =
filter(BaseItem::membyAiringToday) + filterNot(BaseItem::membyAiringToday)
private fun String.showMatchKey(): String =
lowercase().filter(Char::isLetterOrDigit)
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory { class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.ui
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.update.InstallPermission
/**
* The setup step that asks for permission to install updates.
*
* Every TV running Memby was sideloaded, which means the app that installed it holds
* Android's per-app install permission and Memby does not. Without this step nobody is ever
* asked: the permission is discovered to be missing months later, in the middle of an update
* that then cannot proceed, and the household's answer becomes "reinstall it by hand" once
* per release, forever.
*
* It is deliberately **skippable**. A fresh install must never be blocked by a permission
* that only matters later, and on a TV with no permission screen to open there would be
* nothing the viewer could do to satisfy it. Skipping costs them the same prompt at update
* time, which is where it used to live.
*/
@Composable
fun InstallPermissionScreen(onContinue: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var noScreenAvailable by remember { mutableStateOf(false) }
var asked by remember { mutableStateOf(false) }
// Granting happens in Android's settings, which pauses Memby. Coming back with the
// permission in hand should simply carry on rather than leave the viewer looking at a
// step they have already completed.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) {
onContinue()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
InstallPermissionContent(
noScreenAvailable = noScreenAvailable,
onOpenSettings = {
asked = true
noScreenAvailable = !InstallPermission.requestScreen(context)
},
onSkip = onContinue,
)
}
/**
* The screen itself, stateless so it can be screenshotted in both of its states
* (`OnboardingScreenshotTest`).
*
* The instructions are the substance here. Android's own screen is a bare list of app names
* with switches and no explanation of why anyone was sent there, and on a television the
* viewer has a remote, no back button they trust, and no idea what "unknown sources" means.
* Naming the steps *find Memby, turn it on, press Back* is the difference between a
* permission that gets granted and one that gets abandoned halfway.
*/
@Composable
fun InstallPermissionContent(
noScreenAvailable: Boolean,
onOpenSettings: () -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier,
) {
val allowFocus = remember { FocusRequester() }
val skipFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { allowFocus.requestFocus() } }
Box(
modifier.fillMaxSize().background(MembySurface),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 780.dp).padding(horizontal = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Let Memby update itself",
color = MembyOnSurface,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(14.dp))
Text(
"Memby installs its own updates. Because it was installed from a file rather " +
"than a store, Android needs your permission once before it will let it.",
color = MembyMutedText,
fontSize = 17.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
if (noScreenAvailable) {
PermissionStep(1, "Open ${InstallPermission.MANUAL_PATH} on this TV.")
PermissionStep(2, "Find Memby in the list and turn it on.")
PermissionStep(3, "Come back here and carry on.")
} else {
PermissionStep(1, "Choose Open settings below.")
PermissionStep(2, "Turn on the switch for Memby.")
PermissionStep(3, "Press Back on your remote — Memby carries on by itself.")
}
Spacer(Modifier.height(18.dp))
Text(
"You can skip this. Memby will ask again the first time an update needs it, " +
"and until then nothing changes.",
color = MembyQuietText,
fontSize = 14.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(30.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
UpdateButton(
label = if (noScreenAvailable) "Try again" else "Open settings",
primary = true,
enabled = true,
onClick = onOpenSettings,
modifier = Modifier
.focusRequester(allowFocus)
.focusProperties { right = skipFocus },
)
UpdateButton(
label = "Skip for now",
primary = false,
enabled = true,
onClick = onSkip,
modifier = Modifier
.focusRequester(skipFocus)
.focusProperties { left = allowFocus },
)
}
}
}
}
/** One numbered instruction. Never focusable — reading matter, not a control. */
@Composable
private fun PermissionStep(number: Int, text: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"$number",
color = MembyAccent,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(20.dp),
textAlign = TextAlign.End,
)
Text(text, color = MembyOnSurface.copy(alpha = 0.88f), fontSize = 16.sp)
}
}
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -54,11 +55,12 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import com.ponzischeme89.memby.ui.theme.MembyAccent
private val MaintenanceAccent = Color(0xFF52B54B) private val MaintenanceAccent: Color get() = MembyAccent
private val MaintenanceTitle = Color(0xFFF2F5F7) private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF) private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFF7E888F) private val MaintenanceFaint = Color(0xFFA2ADB5)
/** How long between automatic retries while the gateway is down. */ /** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30 private const val RETRY_SECONDS = 30
@@ -117,7 +119,7 @@ fun MaintenanceScreen(
label = "maintenance-entrance", label = "maintenance-entrance",
) )
var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) } var secondsLeft by remember { mutableIntStateOf(RETRY_SECONDS) }
LaunchedEffect(message) { LaunchedEffect(message) {
// Restarts whenever the message changes, so a failed retry resets the clock. // Restarts whenever the message changes, so a failed retry resets the clock.
secondsLeft = RETRY_SECONDS secondsLeft = RETRY_SECONDS
@@ -194,6 +196,21 @@ fun MaintenanceScreen(
} }
} }
// The animations here are infinite, so a preview catches them mid-phase. That is fine for
// checking layout and colour; the motion itself only reads correctly on a device.
@TvPreview
@Composable
private fun MaintenanceScreenPreview() {
PreviewSurface {
MaintenanceScreen(
message = "Back after dinner — the library is being reorganised.",
contentFocusRequester = remember { FocusRequester() },
navigationFocusRequester = remember { FocusRequester() },
onRetry = {},
)
}
}
/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */ /** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */
@Composable @Composable
private fun MaintenanceBackdrop(glow: Float) { private fun MaintenanceBackdrop(glow: Float) {
@@ -0,0 +1,310 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
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.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
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 = ServiceLocator.repository.currentSettings)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
}
MediaDetailContent(
item = item,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
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,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
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)),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
add(
DetailHeroAction(
// A heart, not a tick: "Mark watched" two buttons along is a tick as
// well. The heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
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,289 @@
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.runtime.Composable
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.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.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
@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(MembySurfaceRaised)
.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 { (status, label) ->
LifecycleBadge(
status = status,
label = label,
modifier = Modifier.align(Alignment.TopStart).padding(8.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(),
)
}
}
}
/**
* The tag a followed show wears, as a `status to label` pair for [LifecycleBadge] the same
* vocabulary and therefore the same colours as the Sonarr and Radarr schedule rows, so one
* word means one thing wherever it appears on the launcher.
*
* The order is the priority. A cancelled show wins outright: nothing else on the card
* matters as much as "there will be no more of this", which is why it is the one tag drawn
* in red. Sonarr not monitoring it comes next, then a dated next episode, which is more
* useful than the general fact that the show continues.
*/
internal fun myShowBadge(show: MyShow): Pair<String, String>? = when {
show.lifecycle == "Cancelled" -> "ended" to "CANCELLED"
show.sonarrStatus == "Not monitored" -> "unmonitored" to "UNMONITORED"
!show.nextEpisode.isNullOrBlank() -> "upcoming" to "UPCOMING"
show.lifecycle == "Continuing" -> "continuing" to "CONTINUING"
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(MembySurfaceRaised)
.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)
}
}
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,47 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import com.ponzischeme89.memby.ui.theme.MembyTheme
/**
* The one preview shape this app uses: a 1080p TV, landscape, on the launcher's own
* near-black. A phone-sized preview would be actively misleading every layout here is
* built for a 10-foot view and D-pad focus.
*
* Previews render the composable only; they do not run `ServiceLocator`, so anything
* previewable has to take its state as parameters rather than reading the repository.
* That is worth keeping: it is the same property that makes a composable testable.
*/
@Preview(
name = "TV 1080p",
device = Devices.TV_1080p,
showBackground = true,
backgroundColor = 0xFF090B0D,
)
annotation class TvPreview
/** Wraps preview content in the real theme, so type and colours match the running app. */
@Composable
fun PreviewSurface(
alignment: Alignment = Alignment.Center,
content: @Composable () -> Unit,
) {
MembyTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF090B0D)),
contentAlignment = alignment,
) {
content()
}
}
}
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
/**
* A lifecycle boundary for state that belongs to one signed-in Emby profile.
*
* The owner is remembered by the profile screen and cleared when that screen leaves
* composition. Clearing it cancels the old profile's ViewModel jobs as well as dropping
* its rows and caches before another profile is rendered.
*/
internal class ProfileViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
fun clear() {
viewModelStore.clear()
}
}
@@ -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,201 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.data.model.formattedScore
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
import com.ponzischeme89.memby.data.model.wordmark
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyRatingsSurface
import kotlin.math.roundToInt
private val RatingStripHeight = 34.dp
private val RatingMarkHeight = 19.dp
private val RatingMarkHeightCompact = 16.dp
/** One non-focusable, layout-stable presentation for ratings everywhere in the app. */
@Composable
fun RatingsStrip(
ratings: List<MediaRating>,
visible: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = false,
compact: Boolean = false,
) {
if (!visible) return
val valid = remember(ratings) { ratings.displayable() }
if (valid.isEmpty()) {
if (reserveSpace) Spacer(modifier.height(RatingStripHeight))
return
}
BoxWithConstraints(
modifier = modifier
.then(if (reserveSpace) Modifier.height(RatingStripHeight) else Modifier)
.fillMaxWidth()
.clipToBounds()
.testTag("ratings-strip"),
contentAlignment = Alignment.CenterStart,
) {
// The responsive limit is based on the usable width inside the capsule.
val contentWidth = (maxWidth.value.roundToInt() - 24).coerceAtLeast(0)
val shown = valid.take(ratingDisplayLimit(contentWidth))
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(MembyRatingsSurface)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 13.dp),
) {
shown.forEach { rating ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
RatingMark(rating, compact)
Text(
rating.formattedScore(),
color = MembyOnSurface,
fontSize = if (compact) 13.sp else 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
}
}
/**
* Lazy item adapter.
*
* A card whose payload already carries ratings draws them with the row, costing nothing:
* the gateway attaches what it has stored to every item it sends. Only a title it has
* never looked up falls back to the dedicated request, which cards make on focus and
* heroes and detail pages make immediately.
*/
@Composable
fun ItemRatingsStrip(
item: BaseItem,
load: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = true,
compact: Boolean = false,
) {
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var ratings by remember(item.id) { mutableStateOf(item.membyRatings) }
// A focused home item is replaced in-place by its full Emby details. Include the
// carried ratings in the key so an item can pick them up from the settled response
// without requiring the viewer to move focus away and back.
LaunchedEffect(item.id, item.membyRatings, load, settings.showRatingsStrip) {
if (item.membyRatings.isNotEmpty()) {
ratings = item.membyRatings
return@LaunchedEffect
}
if (load && settings.showRatingsStrip) ratings = ServiceLocator.repository.getRatings(item)
}
RatingsStrip(
ratings = ratings,
visible = settings.showRatingsStrip,
modifier = modifier,
reserveSpace = reserveSpace,
compact = compact,
)
}
/**
* A provider is shown by its own mark where one exists and by its wordmark where it does
* not the strip has to stay readable for a source no build anticipated, and the score
* beside a bare wordmark says nothing about where it came from.
*/
@Composable
private fun RatingMark(rating: MediaRating, compact: Boolean) {
val icon = ratingIcon(rating.source)
if (icon != null) {
val height = (if (compact) RatingMarkHeightCompact else RatingMarkHeight) *
markScale(rating.source)
Image(
painter = painterResource(icon),
contentDescription = rating.wordmark(),
contentScale = ContentScale.Fit,
modifier = Modifier.height(height),
)
} else {
Text(
rating.wordmark(),
color = providerColor(rating.source),
fontSize = if (compact) 12.sp else 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
/**
* Only the critics' tomato is drawn as a tomato: the audience score is a different
* measurement and wearing the same mark would claim it was the same number.
*/
private fun ratingIcon(source: String): Int? = when (source.lowercase()) {
"tomatoes" -> R.drawable.ic_rating_tomatoes
"metacritic" -> R.drawable.ic_rating_metacritic
"imdb" -> R.drawable.ic_rating_imdb
"letterboxd" -> R.drawable.ic_rating_letterboxd
"tmdb" -> R.drawable.ic_rating_tmdb
else -> null
}
/**
* Marks are set to a common height, which is the wrong measure for artwork that stacks
* its type on two lines: TMDb's lettering ends up half the size of everyone else's. The
* scale brings its letters back to the same cap height rather than its box.
*/
private fun markScale(source: String): Float =
if (source.lowercase() == "tmdb") 1.25f else 1f
private fun providerColor(source: String): Color = when (source.lowercase()) {
"imdb" -> Color(0xFFF5C518)
"tomatoes", "audience" -> Color(0xFFFA5252)
"metacritic" -> Color(0xFFFFCC34)
"letterboxd" -> Color(0xFF40BCF4)
"tmdb" -> Color(0xFF90CEA1)
"trakt" -> Color(0xFFED1C24)
"mal", "anilist", "anidb", "kitsu" -> Color(0xFF74A8FF)
else -> MembyMutedText
}
@@ -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)
}
@@ -0,0 +1,715 @@
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.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.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.RelatedContent
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.estimateSeriesPace
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.formatAirDate
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
import java.util.TimeZone
/**
* A series. Loads its episodes and hands them to [SeriesDetailContent], which is where the
* layout lives everything that decides how the screen looks is a parameter there, so it
* can be previewed and screenshotted without a server behind it.
*/
@Composable
fun SeriesDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings)
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
runCatching { repository.getSeriesEpisodes(item.id) }
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
.onFailure {
loadFailed = true
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)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
SeriesDetailContent(
item = item,
episodes = episodes,
loadFailed = loadFailed,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow,
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
onOpenItem = onOpenItem,
airingNotice = airingNotice,
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,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
) {
val remembered = remember(item.id) { detailPositions.get(item.id) }
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
var selectedSeason by rememberSaveable(item.id) { mutableStateOf(remembered.season) }
LaunchedEffect(seasons) {
if (selectedSeason !in seasons) {
selectedSeason = defaultSeason(episodes.orEmpty())
}
}
val seasonEpisodes = remember(episodes, selectedSeason) {
episodesForSeason(episodes.orEmpty(), selectedSeason)
}
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
// Recalculated from the episode list itself, so finishing an episode, marking one
// watched and a newly imported episode all move it with no cache to invalidate. The
// day is read once per composition of this page rather than on a timer: an estimate
// that ticks over at midnight under a page nobody is looking at is not worth a clock.
val paceLabel = remember(episodes, item.id) {
seriesPaceLabel(
estimateSeriesPace(
episodes = episodes.orEmpty(),
nowMs = System.currentTimeMillis(),
zoneOffsetMs = TimeZone.getDefault().getOffset(System.currentTimeMillis()),
ongoing = item.isOngoingSeries,
),
)
}
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)
}
// Fixed on the first frame from what the item *is*. A series has episodes, a cast and
// technical details whether or not they have arrived yet, and a strip that waits for
// the network is a strip that moves under the viewer's thumb.
val tabs = remember(item.id) { detailTabs(isSeries = true) }
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
// A series opens on its cinematic hero and Overview just like a movie. Season and
// rail positions are still remembered once the viewer enters Episodes.
var tabKey by rememberSaveable(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane: Overview, Cast & Details and the Episodes empty states all
// shared a single "information pane" requester, which left it attached to two live
// nodes while AnimatedContent faded the outgoing one out.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
val episodesPane = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val seasonFocusRequesters = remember(seasons) { seasons.associateWith { FocusRequester() } }
val episodeListState = rememberLazyListState(remembered.episodeIndex)
val relatedListState = rememberLazyListState(remembered.relatedIndex)
// Every one of these targets has to be attached to something on screen *right now*. A
// `focusProperties` pointing at a requester that was never placed throws the moment the
// viewer presses that direction — and while the episode request is in flight, on a
// one-season show, or on any tab other than Episodes, neither the chips nor the cards
// exist. [FocusRequester.Default] hands the press back to ordinary focus search.
val episodesOpen = selectedTab == DetailTab.EPISODES
val seasonStripShown = episodesOpen && seasons.size > 1
val hasEpisodeCards = episodesOpen && seasonEpisodes.isNotEmpty()
val selectedSeasonChip =
if (seasonStripShown) seasonFocusRequesters[selectedSeason] else null
val episodeEntry = selectedSeasonChip
?: firstEpisode.takeIf { hasEpisodeCards }
?: episodesPane
val belowSeasons = if (hasEpisodeCards) firstEpisode else FocusRequester.Default
val aboveEpisodes = selectedSeasonChip ?: tabStrip
val contentEntry = when (selectedTab) {
DetailTab.EPISODES -> episodeEntry
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
else -> overviewPane
}
DetailPositionMemory(
itemId = item.id,
tabKey = tabKey,
season = selectedSeason,
episodeIndex = { episodeListState.firstVisibleItemIndex },
relatedIndex = { relatedListState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
zone = DetailZone.PLAY,
play = play,
tabStrip = tabStrip,
related = firstRelated,
relatedReady = visibleRelated.isNotEmpty(),
content = contentEntry,
contentReady = true,
)
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
LaunchedEffect(confirmation) {
if (confirmation != null) {
kotlinx.coroutines.delay(1_800L)
confirmation = null
}
}
DetailPageScaffold(
item = item,
facts = heroFacts(item, seasons.size),
// A series' streams describe its episodes; when Emby gives them, they are as true
// of the show as they are of a film, and the row this page opened from already
// badges them.
badges = mediaBadges(item),
tabs = tabs,
selectedTab = selectedTab,
onSelectTab = { tabKey = it.key },
playLabel = primaryActionLabel(item, nextEpisode),
onPlay = { onPlay(nextEpisode ?: item) },
playFocusRequester = play,
tabFocusRequester = tabStrip,
contentFocusRequester = contentEntry,
modifier = modifier,
progress = nextEpisode?.let(::playbackProgress) ?: 0f,
progressLabel = nextEpisode?.let(::remainingLabel),
paceLabel = paceLabel,
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
airingNotice = airingNotice,
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
add(
DetailHeroAction(
// A heart, not a plus: the bookmark beside it is also a "+" glyph, and
// two adjacent circles that both read as "add this" say nothing about
// which list is which. The heart is already what a home card, the
// screensaver and the Favourites row header use for this.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
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"
},
),
)
if (ServerConfig.isGateway) {
add(DetailHeroAction(
// Filled/outline says on-or-off for both toggles, so the two differ by
// silhouette alone — heart against bookmark. The +/✓ variants said it
// a second way and borrowed the glyphs the other buttons use.
icon = if (isMyShow) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
active = isMyShow,
onClick = { onToggleMyShow(item, !isMyShow) },
))
}
trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
}
},
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(
item = item,
credits = credits,
focusRequester = overviewPane,
// The progress bar carries the position, so this only names the episode.
supportingText = nextEpisode?.let { "Up next ${episodeHeadline(it)}" },
)
DetailTab.EPISODES -> EpisodesPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
seasons = seasons,
selectedSeason = selectedSeason,
remaining = remaining,
loadFailed = loadFailed,
seasonStripShown = seasonStripShown,
seasonFocusRequesters = seasonFocusRequesters,
firstEpisodeFocusRequester = firstEpisode,
belowSeasons = belowSeasons,
aboveEpisodes = aboveEpisodes,
tabFocusRequester = tabStrip,
listState = episodeListState,
emptyFocusRequester = episodesPane,
onSelectSeason = { selectedSeason = it },
onPlay = onPlay,
)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
)
}
}
}
@Composable
private fun EpisodesPane(
episodes: List<BaseItem>?,
seasonEpisodes: List<BaseItem>,
seasons: List<Int>,
selectedSeason: Int?,
remaining: Int,
loadFailed: Boolean,
seasonStripShown: Boolean,
seasonFocusRequesters: Map<Int, FocusRequester>,
firstEpisodeFocusRequester: FocusRequester,
belowSeasons: FocusRequester,
aboveEpisodes: FocusRequester,
tabFocusRequester: FocusRequester,
listState: LazyListState,
emptyFocusRequester: FocusRequester,
onSelectSeason: (Int) -> Unit,
onPlay: (BaseItem) -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (seasonStripShown) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
) {
items(seasons, key = { it }) { season ->
SeasonChip(
season = season,
selected = season == selectedSeason,
focusRequester = seasonFocusRequesters.getValue(season),
episodeFocusRequester = belowSeasons,
tabFocusRequester = tabFocusRequester,
onClick = { onSelectSeason(season) },
)
}
}
} else if (seasons.size == 1) {
Text(
text = seasonLabel(seasons.first()),
color = DetailMutedText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
if (episodes != null && remaining > 0) {
Text(
text = "$remaining unwatched",
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.padding(start = 14.dp),
)
}
}
Spacer(Modifier.height(14.dp))
when {
episodes == null -> DetailFocusablePane(emptyFocusRequester) { Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp) }
loadFailed -> DetailFocusablePane(emptyFocusRequester) { Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp) }
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { Text("No episodes are available.", color = DetailQuietText, fontSize = 15.sp) }
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
EpisodeCard(
episode = episode,
onClick = { onPlay(episode) },
seasonFocusRequester = aboveEpisodes,
isFirst = index == 0,
modifier = if (index == 0) {
Modifier.focusRequester(firstEpisodeFocusRequester)
} else {
Modifier
},
)
}
}
}
}
}
@Composable
private fun SeasonChip(
season: Int,
selected: Boolean,
focusRequester: FocusRequester,
episodeFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(7.dp)
Box(
modifier = Modifier
.clip(shape)
.background(
when {
focused -> Color.White
selected -> DetailAccent
else -> Color.White.copy(alpha = 0.07f)
},
)
.border(1.dp, if (selected || focused) Color.Transparent else DetailHairline, shape)
.focusRequester(focusRequester)
.focusProperties {
up = tabFocusRequester
down = episodeFocusRequester
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 7.dp),
) {
Text(
text = seasonLabel(season),
color = if (focused) Color.Black else Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
/**
* A minimal D-pad row: the focused episode becomes the unmistakable selection.
*
* Shared with the episode page, which passes [isCurrent] for the one the page is about
* the season's list is otherwise identical there, and two copies of a row this fiddly is
* how the two screens start disagreeing about what a watched episode looks like.
*/
@Composable
internal fun EpisodeCard(
episode: BaseItem,
onClick: () -> Unit,
seasonFocusRequester: FocusRequester,
isFirst: Boolean,
modifier: Modifier = Modifier,
isCurrent: Boolean = false,
) {
val repository = ServiceLocator.repository
var focused by remember { mutableStateOf(false) }
val imageUrl = remember(episode.id) {
repository.primaryUrl(episode, 480) ?: repository.backdropUrl(episode, 480)
}
val shape = RoundedCornerShape(8.dp)
val progress = remember(episode.userData, episode.runTimeTicks) { playbackProgress(episode) }
val scale by animateFloatAsState(
targetValue = if (focused) 1.01f else 1f,
animationSpec = tween(100),
label = "episode-card-focus",
)
Row(
modifier = modifier
.fillMaxWidth()
.height(132.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
translationY = if (focused) -1f else 0f
}
.zIndex(if (focused) 1f else 0f)
.clip(shape)
.background(
when {
focused -> Color(0xFF23282C)
isCurrent -> DetailAccent.copy(alpha = 0.10f)
else -> Color.White.copy(alpha = 0.035f)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Color.White.copy(alpha = 0.30f)
isCurrent -> DetailAccent.copy(alpha = 0.55f)
else -> Color.White.copy(alpha = 0.08f)
},
shape = shape,
)
.focusProperties {
up = if (isFirst) seasonFocusRequester else FocusRequester.Default
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(208.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFF15181C)),
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
Modifier
.fillMaxSize()
.border(
width = 1.dp,
color = if (focused) Color.White.copy(alpha = 0.28f) else Color.White.copy(alpha = 0.10f),
shape = RoundedCornerShape(6.dp),
),
)
if (focused) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.76f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play selected episode",
tint = Color.White,
modifier = Modifier.size(25.dp),
)
}
}
if (episode.isPlayed) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Watched",
tint = DetailAccent,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(16.dp),
)
}
if (progress > 0f) {
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(3.dp)
.background(Color.Black.copy(alpha = 0.65f)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(3.dp)
.background(DetailAccent),
)
}
}
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
episode.indexNumber?.let {
Text(
text = "$it.",
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 7.dp),
)
}
Text(
text = episode.name,
color = if (focused) Color.White else DetailText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Text(
text = "THIS EPISODE",
color = DetailAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.padding(start = 10.dp)
.clip(RoundedCornerShape(5.dp))
.background(DetailAccent.copy(alpha = 0.16f))
.padding(horizontal = 7.dp, vertical = 3.dp),
)
}
formatAirDate(episode.premiereDate)?.let {
Text(
text = it,
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.padding(start = 12.dp),
)
}
episode.runtimeMinutes?.let {
Text(
text = "$it min",
color = if (focused) Color(0xFFE4E8EA) else DetailText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.padding(start = 12.dp)
.clip(RoundedCornerShape(5.dp))
.background(
if (focused) Color.White.copy(alpha = 0.10f)
else Color.White.copy(alpha = 0.08f),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
Spacer(Modifier.height(7.dp))
Text(
text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.",
color = if (focused) Color(0xFFC4CBD0) else DetailMutedText,
fontSize = 13.sp,
lineHeight = 18.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(12.dp))
}
}
@@ -0,0 +1,364 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.ServiceAlert
import kotlin.math.ceil
import com.ponzischeme89.memby.ui.theme.MembyAccent
private val AlertAccent: Color get() = MembyAccent
private val AlertTitle = Color(0xFFF2F5F7)
private val AlertBody = Color(0xFFC3CBD2)
/**
* Height of the bar itself, before the rule and fade that blend it into the screen.
*
* 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
* nothing meaningful may sit closer than this to the frame.
*/
private val SafeAreaHorizontal = 48.dp
/**
* A full-width bar that drops in over the top of the screen to say an episode has aired
* the shape a broadcaster uses for an in-programme notice, rather than a corner toast.
*
* Deliberately not focusable and not dismissable by D-pad: it must never steal focus from
* a row mid-browse, so it times itself out instead of asking the viewer to act. It spans
* the navigation rail as well, because for its few seconds it is the top layer of the
* screen; it draws above the rows but below the update prompt, which is the one thing
* allowed to own the whole display.
*/
@Composable
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
// The alert is collected *here* rather than passed down from the home composable.
// Read one scope up and every arriving alert would recompose the entire launcher
// body; read here and it recomposes a bar that is usually not even on screen.
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
val alert = current?.takeUnless { suppressed }
// Tell the monitor the moment this is really on screen. Until it hears that, the
// alert is only offered — it starts no dismissal timer and persists nothing — so an
// alert arriving behind the screensaver or another app is not silently used up.
LaunchedEffect(alert?.id) {
alert?.id?.let { ServiceLocator.maintenance.alertShown(it) }
}
// The exit animation still needs something to draw, so hold the last alert until the
// slide-out has finished with it.
var lastAlert by remember { mutableStateOf<ServiceAlert?>(null) }
if (alert != null) lastAlert = alert
AnimatedVisibility(
visible = alert != null,
// In from above the frame, out the same way, both unhurried: over a film the
// arrival is the intrusion, so it eases in rather than snapping down. Still
// slower arriving than leaving — showing up should be noticed, going away
// should not.
enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(520)),
exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(300)),
modifier = modifier.zIndex(8f),
) {
lastAlert?.let { AlertBanner(it) }
}
}
// Internal so the screenshot test can render the bar on its own, without the drop-in
// wrapper around it.
@Composable
internal fun AlertBanner(
alert: ServiceAlert,
visibleMillis: Long = MaintenanceMonitor.ALERT_VISIBLE_MS,
) {
// Keyed on the alert id so a second banner arriving restarts the countdown rather
// than inheriting whatever was left of the first one's.
val remaining = remember(alert.id) { Animatable(1f) }
LaunchedEffect(alert.id) {
remaining.animateTo(0f, tween(visibleMillis.toInt(), easing = LinearEasing))
}
// The ring's fraction is handed down as a lambda and the seconds as derived state, so
// ten seconds of animation costs ~10 recompositions of one number instead of ~600 of
// this whole bar. On a weak TV box that difference is the feature's entire cost.
val secondsLeft = remember(alert.id, visibleMillis) {
derivedStateOf { ceil(remaining.value * visibleMillis / 1000f).toInt() }
}
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(BannerHeight)
.background(
// Near-black, and darkest at the left where the text sits. Over a
// film almost anything can be behind this, so the bar supplies its
// own contrast rather than relying on the picture underneath.
Brush.horizontalGradient(
0f to Color(0xFF04070A),
0.55f to Color(0xF504070A),
1f to Color(0xE0070B0F),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
AlertMark()
Spacer(Modifier.width(14.dp))
// The same short green rule used by the ten-minute reminder. It makes the
// alert feel like part of the player instead of a separate notification UI.
Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent))
Spacer(Modifier.width(14.dp))
Text(
alertLabel(alert.label),
color = AlertAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(12.dp))
Text(
alert.title,
color = AlertTitle,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(10.dp))
Text(
alert.message,
color = AlertBody,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
}
// An accent rule under the bar, then a short fade: without them the banner ends
// on a hard line across the artwork, which reads as a rendering seam.
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
Brush.horizontalGradient(
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
),
),
)
Box(
Modifier
.fillMaxWidth()
.height(10.dp)
.background(
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.
*
* This exists to answer "is it about to go, or did it stick?" the banner cannot be
* dismissed by remote, so the one thing the viewer can usefully know is how long they
* have to read it. Drawn in a single Canvas: an arc and a track are cheaper than any
* progress component, and this animates every frame for ten seconds.
*
* [fraction] is a lambda and [secondsLeft] a [State] on purpose. Read either one in a
* composable body and every frame recomposes; read them inside the draw lambda and in a
* derived state, and the sweep costs a redraw only.
*/
@Composable
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val stroke = 2.dp.toPx()
val inset = stroke / 2f
val arcSize = Size(size.width - stroke, size.height - stroke)
drawArc(
color = Color.White.copy(alpha = 0.12f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
drawArc(
color = AlertAccent,
// Twelve o'clock, unwinding clockwise: the direction a clock hand sweeps,
// so "less arc left" reads as "less time left" without a legend.
startAngle = -90f,
sweepAngle = 360f * fraction().coerceIn(0f, 1f),
useCenter = false,
topLeft = Offset(inset, inset),
size = arcSize,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
}
Text(
secondsLeft.value.coerceAtLeast(0).toString(),
color = AlertBody,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
)
}
}
/**
* The Emby mark, which is what every one of these banners is really speaking for
* a new film, a finished refresh, a server that stopped answering.
*
* It replaced the item poster deliberately: artwork made each alert look like a different
* feature, and half of them (a library refresh, an outage) have no artwork to show. One
* constant mark says "this is your server talking" in the width of a thumbnail.
*/
@Composable
private fun AlertMark() {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.width(30.dp)
.height(26.dp),
)
}
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
// whole job is the drop-in, and a still frame of an animation in progress says nothing.
// Posters are left null on purpose — the preview has no network, so this is also the
// fallback tile being checked.
@TvPreview
@Composable
private fun ServiceAlertBannerPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
),
)
}
}
/** 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",
),
)
}
}
/** Long strings are common; the single-line ellipsis is the part worth eyeballing. */
@TvPreview
@Composable
private fun ServiceAlertBannerLongTitlePreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(
ServiceAlert(
id = "sonarr:9:88:aired",
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"aired at 10:30 PM and is downloading now.",
),
)
}
}
@@ -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,30 +42,40 @@ 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.AppInstall
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
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurface
private val UpdateAccent = Color(0xFF52B54B) private val UpdateAccent: Color get() = MembyAccent
private val UpdateTitle = Color(0xFFF2F5F7) private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF) private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFF7E888F) 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 +93,58 @@ 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…"
}
}
// The install happens in the system installer, so its outcome arrives here rather than
// as the result of the call above. On a mandatory update this is the only thing that
// can tell a viewer why the screen they cannot leave is still there.
LaunchedEffect(Unit) { AppInstall.messages.collect { message = it } }
// 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 +177,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(MembySurface),
// 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 +260,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 +273,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,13 +305,20 @@ 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,
)
} }
} }
} }
} }
@Composable @Composable
private fun UpdateButton( internal fun UpdateButton(
label: String, label: String,
primary: Boolean, primary: Boolean,
enabled: Boolean, enabled: Boolean,
@@ -290,6 +350,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,28 @@
package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned actions My Alerts, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
activeProfileId: String?,
): Int = profileIds.indexOf(activeProfileId).takeIf { it >= 0 } ?: 0
internal fun userSwitcherNextIndex(
currentIndex: Int,
profileCount: Int,
direction: UserSwitcherDirection,
actionCount: Int = 1,
): Int {
val lastIndex = (profileCount.coerceAtLeast(0) + actionCount.coerceAtLeast(1) - 1)
.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, lastIndex)
return when (direction) {
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
}
}
@@ -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. Youve earned the good seat.",
),
WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.",
"Pick something cheerful. Ive 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)}"
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui.alerts
/**
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once.
*
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and
* not unread ones: an alert that has been read but not dismissed is still sitting there, and
* a badge that cleared itself the moment somebody glanced at the page would be a badge that
* never agreed with the list underneath it.
*/
/** Above this the badge stops counting and says so, or the pill grows wider than its row. */
internal const val AlertBadgeMax = 9
internal fun alertBadgeLabel(count: Int): String? = when {
count <= 0 -> null
count > AlertBadgeMax -> "$AlertBadgeMax+"
else -> count.toString()
}
/**
* The line under the page heading. "New" is the unread half worth naming, since it is the
* only thing distinguishing two otherwise identical rows but it is never claimed on its
* own, because a page saying "2 new" above three alerts reads as having lost one.
*/
internal fun alertsSummary(total: Int, unread: Int): String = when {
total <= 0 -> "Nothing waiting for you"
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts"
else -> {
val alerts = if (total == 1) "1 alert" else "$total alerts"
"$alerts · $unread new"
}
}
@@ -0,0 +1,313 @@
package com.ponzischeme89.memby.ui.alerts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import kotlinx.coroutines.delay
/**
* My Alerts a full page rather than the panel it used to be, reached from the user menu in
* the user picker.
*
* It moved there because these alerts belong to a *person*, not to a television: they follow
* whoever is signed in, so the place that already answers "who is watching" is where somebody
* looks for their own news. The bell it replaced sat in the corner of the launcher, was only
* drawn on Home, and cost a focus target on every set in the house whether or not there was
* anything behind it.
*
* It reads like Settings on purpose black canvas, flat rows on a shared 16dp inset with
* hairlines between them, and the row under focus the only lit surface on the page.
*
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
* and a second confirmation press on every alert is what made the old panel not worth
* opening. And **focus marks read** a row can only be read by being looked at, so nothing
* has to be pressed to clear the "new" flag on it.
*
* Stateless by design: the caller owns the list and the requests, so this can be previewed
* and screenshotted with no server.
*/
@Composable
fun MyAlertsPage(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismiss: (UserNotification) -> Unit,
onDismissAll: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val listFocusRequester = remember { FocusRequester() }
val actionsFocusRequester = remember { FocusRequester() }
val hasAlerts = notifications.isNotEmpty()
LaunchedEffect(hasAlerts) {
// One frame for the list to place its first row; an empty page has nothing below
// the actions to land on, so the chips take the remote instead.
delay(16)
runCatching {
if (hasAlerts) listFocusRequester.requestFocus() else actionsFocusRequester.requestFocus()
}
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp)
.padding(top = 40.dp, bottom = 28.dp),
) {
AlertsHeader(
total = notifications.size,
unread = notifications.count(UserNotification::unread),
)
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
)
MembyChoiceChip(
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
selected = preferences.enabled && preferences.showReturnAlerts,
onClick = { if (preferences.enabled) onToggleShowReturns() },
)
Spacer(Modifier.width(1.dp))
if (hasAlerts) {
MembyChoiceChip(
label = "Dismiss all",
selected = false,
onClick = onDismissAll,
)
}
Spacer(Modifier.weight(1f))
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
}
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (!hasAlerts) {
AlertsEmptyState(enabled = preferences.enabled)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp),
) {
items(notifications, key = UserNotification::id) { notification ->
AlertRow(
notification = notification,
modifier = if (notification.id == notifications.first().id) {
Modifier.focusRequester(listFocusRequester)
} else {
Modifier
},
onFocused = { if (notification.unread) onRead(notification) },
onClick = { onDismiss(notification) },
)
if (notification.id != notifications.last().id) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.background(MembyHairline),
)
}
}
}
}
}
}
}
@Composable
private fun AlertsHeader(total: Int, unread: Int) {
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
}
}
@Composable
private fun AlertsEmptyState(enabled: Boolean) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Alerts about the shows you follow will show up here."
} else {
"Alerts are switched off, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
)
}
}
@Composable
private fun AlertRow(
notification: UserNotification,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(shape)
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = shape,
)
.clickable(onClick = onClick)
.semantics {
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
),
contentAlignment = Alignment.Center,
) {
Icon(
alertIcon(notification.kind),
contentDescription = null,
tint = if (notification.unread) MembyAccent else MembyQuietText,
modifier = Modifier.size(19.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (notification.unread) {
Text(
"NEW",
color = MembyAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent.copy(alpha = 0.14f))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
Text(
notification.message,
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1)
}
}
// The hint is the whole reason a single press is allowed to dismiss: it is stated on
// the row about to go, and only on the row under focus.
Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) {
if (focused) {
Text(
"OK to dismiss",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive
}
@@ -0,0 +1,82 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import java.util.Locale
/**
* What a detail page says when it was opened from the "Shows airing in the next 5 days"
* row: which episode is due, and when.
*
* It exists because that row's cards stand for an *episode* while the page they open is
* the *show*. Without it the viewer picks "Thursday, 9pm" from the launcher and arrives at
* a page that says nothing about Thursday. It is deliberately a property of the route
* taken, not of the show the same page reached from Favourites or a search carries no
* notice, because nothing there promised one.
*
* Every field is wording the gateway already authored ([BaseItem.membyAirLabel] and
* friends), so the TV never computes an air time from a timestamp and can never contradict
* the card the viewer just pressed.
*/
data class AiringNotice(
/** The eyebrow: "AIRING TOMORROW", "AIRED TODAY", "NEW EPISODE READY". */
val label: String,
/** "S03E04 • The Crossing", or empty when Sonarr named neither. */
val headline: String,
/** "In 8 hours (4:00 PM) • Awaiting download". */
val detail: String,
)
/**
* The notice for a schedule card, or null for anything else including a movie-schedule
* card, which has no episode to announce and opens no series page.
*/
fun airingNoticeFor(item: BaseItem): AiringNotice? {
if (!item.isTvSchedule) return null
val day = item.membyAirDayLabel?.trim().orEmpty()
val airLabel = item.membyAirLabel?.trim().orEmpty()
val availability = item.membyAvailabilityText?.trim().orEmpty()
val headline = listOfNotNull(
item.membyEpisodeCode?.trim()?.takeIf(String::isNotEmpty),
item.membyEpisodeTitle?.trim()?.takeIf(String::isNotEmpty),
).joinToString(FactSeparator)
val detail = listOf(airLabel, availability)
.filter(String::isNotEmpty)
.joinToString(FactSeparator)
// A notice with nothing to say is worse than none: it would claim a schedule the page
// cannot name.
if (headline.isEmpty() && detail.isEmpty()) return null
return AiringNotice(label = airingNoticeLabel(day, airLabel, item.membyAvailability), headline, detail)
}
/**
* The Emby series a schedule card stands for, as much of it as the card itself knows, or
* null when the library has never imported the show.
*
* The page opens on this and fills in from Emby a moment later, the same way the launcher
* draws its cached rows before the network answers waiting on one item request before
* anything appears is the one thing that would make the row feel broken. The episode's own
* overview and artwork are deliberately left behind: they belong to the episode, and the
* page this becomes is about the show.
*/
fun scheduleSeriesStub(card: BaseItem): BaseItem? {
if (!card.isTvSchedule) return null
val seriesId = card.membySeriesItemId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return BaseItem(
id = seriesId,
name = card.name,
type = "Series",
genres = card.genres,
productionYear = card.productionYear,
)
}
private fun airingNoticeLabel(day: String, airLabel: String, availability: String?): String = when {
// The episode is already on the server, so "airing" would send someone to wait for
// something they could watch now.
availability == "available" -> "NEW EPISODE READY"
airLabel.startsWith("Aired", ignoreCase = true) ->
"AIRED ${day.ifEmpty { "TODAY" }.uppercase(Locale.ROOT)}"
day.isEmpty() || day.equals("Upcoming", ignoreCase = true) -> "UPCOMING EPISODE"
else -> "AIRING ${day.uppercase(Locale.ROOT)}"
}
@@ -0,0 +1,508 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
/**
* Everything the detail pages need to *say*, as pure functions.
*
* None of this touches Compose, the repository or Android, so the whole vocabulary of the
* two screens which season opens first, what the primary button is called, which
* technical facts are worth a line is unit-testable without a device. The composables in
* this package are deliberately thin over these.
*/
/** Emby stores durations and positions as 100-ns ticks. */
private const val TICKS_PER_MINUTE = 600_000_000L
/**
* The video width at which a file is called 4K, by the spec row and the card badge alike.
*
* UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the
* threshold sits just under it. The badge and the `(4K)` suffix used to disagree 3800 and
* 3400 and a 3600-wide file was 4K on one screen and not the other.
*/
const val UHD_MIN_WIDTH = 3_800
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
fun formatRuntime(minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
return when {
hours > 0 && remainder > 0 -> "${hours}h ${remainder}m"
hours > 0 -> "${hours}h"
else -> "${remainder}m"
}
}
/** Position on the playhead, in the same wording as a runtime. */
fun formatPosition(ticks: Long): String =
formatRuntime((ticks / TICKS_PER_MINUTE).toInt().coerceAtLeast(0))
val BaseItem.resumeTicks: Long get() = userData?.playbackPositionTicks ?: 0L
val BaseItem.isPlayed: Boolean get() = userData?.played == true
/** True when there is a meaningful position to resume from. */
val BaseItem.isResumable: Boolean get() = resumeTicks > 0L
/** 0f..1f through the item, or 0f when either end is unknown. */
fun playbackProgress(item: BaseItem): Float {
val runtime = item.runTimeTicks ?: 0L
if (runtime <= 0L) return 0f
return (item.resumeTicks.toFloat() / runtime).coerceIn(0f, 1f)
}
/** "1h 12m left", or null when the remainder cannot be worked out. */
fun remainingLabel(item: BaseItem): String? {
val runtime = item.runTimeTicks ?: return null
val left = runtime - item.resumeTicks
if (item.resumeTicks <= 0L || left <= TICKS_PER_MINUTE) return null
return "${formatPosition(left)} left"
}
/**
* 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()) }
if (seasonCount > 0) {
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
} else {
item.runtimeMinutes?.let { add(formatRuntime(it)) }
}
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
}
/**
* "12 Mar 2024" from Emby's `PremiereDate`, or null when there is no usable date.
*
* Only the date half of the timestamp is read, deliberately. Emby writes a premiere as
* midnight UTC, so resolving it against the set's own zone moves an episode a day earlier
* everywhere west of Greenwich and the day a programme aired is a fact about the
* broadcast, not an instant to be converted. Parsing the literal `YYYY-MM-DD` prefix also
* keeps this free of `java.time`, which this app's `minSdk` 23 cannot use.
*/
fun formatAirDate(raw: String?): String? {
val date = raw?.trim()?.takeIf { it.length >= 10 } ?: return null
if (date[4] != '-' || date[7] != '-') return null
val year = date.substring(0, 4).toIntOrNull() ?: return null
val month = date.substring(5, 7).toIntOrNull() ?: return null
val day = date.substring(8, 10).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return "$day ${MONTH_ABBREVIATIONS[month - 1]} $year"
}
private val MONTH_ABBREVIATIONS = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)
/**
* The label/value credits block: who is in it, who made it, what it is. Kept separate from
* [technicalSpecs] because these answer "do I want this?" and those answer "will it play?".
*/
fun creditRows(item: BaseItem): List<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
* of the headline because none of them decide what to watch.
*/
fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", ignoreCase = true) }
val subtitles = item.mediaStreams.count { it.type.equals("Subtitle", ignoreCase = true) }
return buildList {
video?.let { stream ->
val width = stream.width
val height = stream.height
if (width != null && height != null) {
add(TechnicalSpec("Video", "$width × $height${resolutionSuffix(width)}"))
}
listOfNotNull(
stream.codec?.uppercase(Locale.US),
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
}
}
audio?.let { stream ->
listOfNotNull(
stream.codec?.uppercase(Locale.US),
stream.channels?.let(::channelLabel),
stream.language?.takeIf(String::isNotBlank),
).takeIf(List<String>::isNotEmpty)?.let {
add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
}
}
if (subtitles > 0) {
add(TechnicalSpec("Subtitles", "$subtitles ${if (subtitles == 1) "track" else "tracks"}"))
}
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
}
}
private fun resolutionSuffix(width: Int): String = when {
width >= UHD_MIN_WIDTH -> " (4K)"
width >= 2_500 -> " (1440p)"
width >= 1_800 -> " (1080p)"
width >= 1_200 -> " (720p)"
else -> ""
}
/**
* "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an
* ordinary SDR file. Shared with the card badges so the two never name the same file
* differently HDR10+ used to collapse to a plain "HDR" badge.
*/
internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
return when {
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
"hdr10+" in haystack -> "HDR10+"
"hdr" in haystack -> "HDR"
else -> null
}
}
private fun channelLabel(channels: Int): String = when (channels) {
1 -> "Mono"
2 -> "Stereo"
6 -> "5.1"
8 -> "7.1"
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
// ---------------------------------------------------------------------------
/** Running order: season, then episode, then title for anything unnumbered. */
val seriesEpisodeComparator = compareBy<BaseItem>(
{ it.parentIndexNumber ?: Int.MAX_VALUE },
{ it.indexNumber ?: Int.MAX_VALUE },
{ it.name },
)
/** Season numbers present in the library, specials (0) first. */
fun availableSeasons(episodes: List<BaseItem>): List<Int> =
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
if (season == null) emptyList()
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
/**
* What the viewer should watch next: whatever is part-watched, else the first unwatched
* episode. Specials are skipped while any numbered season exists season 0 sorts first
* but is almost never where someone is up to.
*/
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
if (episodes.isEmpty()) return null
val ordered = episodes.sortedWith(seriesEpisodeComparator)
val hasNumberedSeason = ordered.any { (it.parentIndexNumber ?: 0) > 0 }
val candidates = if (hasNumberedSeason) {
ordered.filter { (it.parentIndexNumber ?: 0) > 0 }
} else {
ordered
}
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
?: candidates.firstOrNull { !it.isPlayed }
}
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
fun defaultSeason(episodes: List<BaseItem>): Int? =
nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
/** How many episodes are still unwatched, for the series badge. */
fun unwatchedCount(episodes: List<BaseItem>): Int = episodes.count { !it.isPlayed }
/** "Season 3", or "Specials" for season 0. */
fun seasonLabel(season: Int): String = if (season == 0) "Specials" else "Season $season"
/** "S2 E4" — shorter than [BaseItem.episodeCode] and used where space is tight. */
fun episodeLabel(episode: BaseItem): String? {
val number = episode.indexNumber ?: return null
val season = episode.parentIndexNumber
return if (season != null) "S$season E$number" else "E$number"
}
/**
* "S04E04" the zero-padded form, used on the primary button and nowhere else.
*
* A button is read at a glance from across a room, and the padded code is the shape a
* viewer recognises without parsing it; [episodeLabel]'s spaced "S4 E4" is for running
* prose like [episodeHeadline], where it sits beside a title. Null when Emby numbered
* neither half, which is when the button has to fall back to a bare verb.
*/
fun paddedEpisodeCode(episode: BaseItem): String? {
val number = episode.indexNumber ?: return null
val season = episode.parentIndexNumber
?: return String.format(Locale.US, "E%02d", number)
return String.format(Locale.US, "S%02dE%02d", season, number)
}
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
fun episodeHeadline(episode: BaseItem): String =
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
.joinToString(ValueSeparator)
// ---------------------------------------------------------------------------
// Episode pages
// ---------------------------------------------------------------------------
/**
* "SEASON 3 · EPISODE 4" the line under the series logo on an episode's own page.
*
* Spelled out rather than [episodeLabel]'s "S3 E4": this is the page's heading, where the
* short form is a code the viewer has to decode, and there is width for the words. Null
* when Emby numbered neither half, which is when the episode title has to carry the page
* on its own.
*/
fun episodeEyebrow(episode: BaseItem): String? {
val season = episode.parentIndexNumber?.let { if (it == 0) "SPECIALS" else "SEASON $it" }
val number = episode.indexNumber?.let { "EPISODE $it" }
return listOfNotNull(season, number).takeIf(List<String>::isNotEmpty)?.joinToString(ValueSeparator)
}
/** Where a season sits relative to the one the viewer is up to. */
enum class SeasonProgress { WATCHED, CURRENT, UPCOMING }
/**
* One stop on the season scroller: what to call it, how far through it the viewer is, and
* whether it is behind them, under them or ahead.
*/
data class SeasonMarker(
val season: Int,
val label: String,
val progress: SeasonProgress,
val watchedEpisodes: Int,
val totalEpisodes: Int,
) {
/** True when every episode the library holds for this season has been played. */
val complete: Boolean get() = totalEpisodes > 0 && watchedEpisodes == totalEpisodes
}
/**
* The scroller that replaces the tab strip on an episode page.
*
* A season counts as [SeasonProgress.WATCHED] when it is *behind* the episode being viewed
* or when nothing in it is left unplayed the two ways a household is done with a season.
* Being behind is enough on its own deliberately: someone who skipped an episode of season
* one and is now three seasons along has moved on, and greying it is the honest summary of
* where they are. Everything after the current season stays
* [SeasonProgress.UPCOMING] however much of it has been sampled, because a scroller that
* dims what is still to come says the show is finished when it is not.
*/
fun seasonMarkers(episodes: List<BaseItem>, currentSeason: Int?): List<SeasonMarker> =
availableSeasons(episodes).map { season ->
val inSeason = episodes.filter { it.parentIndexNumber == season }
val watched = inSeason.count(BaseItem::isPlayed)
val progress = when {
currentSeason != null && season == currentSeason -> SeasonProgress.CURRENT
inSeason.isNotEmpty() && watched == inSeason.size -> SeasonProgress.WATCHED
// Specials sort first but are not on the way to anywhere, so being "before"
// the current season earns them nothing: a tick on a special nobody has
// watched is the one claim this scroller must not make.
currentSeason != null && season in 1 until currentSeason -> SeasonProgress.WATCHED
else -> SeasonProgress.UPCOMING
}
SeasonMarker(
season = season,
label = seasonLabel(season),
progress = progress,
watchedEpisodes = watched,
totalEpisodes = inSeason.size,
)
}
/** "5 of 8 watched", or null for a season the library has nothing for. */
fun seasonProgressLabel(marker: SeasonMarker): String? = when {
marker.totalEpisodes <= 0 -> null
marker.complete && marker.totalEpisodes == 1 -> "Watched"
marker.complete -> "All ${marker.totalEpisodes} watched"
marker.watchedEpisodes == 0 && marker.totalEpisodes == 1 -> "1 episode"
marker.watchedEpisodes == 0 -> "${marker.totalEpisodes} episodes"
else -> "${marker.watchedEpisodes} of ${marker.totalEpisodes} watched"
}
/**
* The whole-series line beside the scroller: "Season 3 of 5 · 12 episodes left".
*
* Null when there is nothing worth saying a one-season show with everything watched has
* no progress to report, and a bar that says so anyway is noise on every episode page.
*/
fun seriesProgressLabel(markers: List<SeasonMarker>, currentSeason: Int?): String? {
if (markers.isEmpty()) return null
// Counted over the numbered seasons only. "Season 3 of 5" on a four-season show with a
// making-of special is wrong in the one place the viewer is most likely to read it.
val numbered = markers.filter { it.season > 0 }
val position = numbered.indexOfFirst { it.season == currentSeason }
val place = if (position >= 0 && numbered.size > 1) {
"${numbered[position].label} of ${numbered.size}"
} else {
null
}
val left = markers.sumOf { it.totalEpisodes - it.watchedEpisodes }
val remaining = when {
left <= 0 -> null
left == 1 -> "1 episode left"
else -> "$left episodes left"
}
return listOfNotNull(place, remaining).takeIf(List<String>::isNotEmpty)?.joinToString(FactSeparator)
}
/**
* The episode that follows [episode] in the same library, or null at the end of the show.
*
* Position in running order decides it, never the length of the list the same rule the
* player's auto-advance follows, so the page's "Up next" and what actually plays cannot
* disagree.
*/
fun episodeAfter(episodes: List<BaseItem>, episode: BaseItem): BaseItem? {
val ordered = episodes.sortedWith(seriesEpisodeComparator)
val index = ordered.indexOfFirst { it.id == episode.id }
return if (index < 0) null else ordered.getOrNull(index + 1)
}
/**
* The primary button. Series pass their next episode so the button can name it; a movie
* passes itself; an episode names itself without being asked.
*
* An episode's button says "Play S04E04" because that page is reached from Continue
* Watching, where the viewer chose a specific episode and the one thing the button has to
* confirm is that it is the one they meant. A bare "Play" on a page headed by the series
* logo could plausibly mean the show.
*/
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String {
val episode = nextEpisode ?: item.takeIf(BaseItem::isEpisode)
if (episode != null) {
val verb = if (episode.isResumable) "Resume" else "Play"
val code = paddedEpisodeCode(episode)
return when {
code != null -> "$verb $code"
// An unnumbered episode of somebody else's show still has to say something.
nextEpisode != null -> "$verb next episode"
else -> verb
}
}
return when {
item.isResumable -> "Resume"
item.isMovie && item.isPlayed -> "Rewatch"
else -> "Play"
}
}
/** The supporting line under the primary button, or null when there is nothing to add. */
fun primaryActionDetail(item: BaseItem, nextEpisode: BaseItem? = null): String? {
val target = nextEpisode ?: item
return when {
target.isResumable -> "From ${formatPosition(target.resumeTicks)}" +
(remainingLabel(target)?.let { " · $it" } ?: "")
nextEpisode != null -> nextEpisode.name.takeIf(String::isNotBlank)
else -> null
}
}
/** Kicker above the title: what kind of thing this page is about. */
fun detailKicker(item: BaseItem): String = when {
item.isMovie -> "MOVIE"
item.isSeries -> "SERIES"
item.isEpisode -> item.seriesName?.uppercase(Locale.US) ?: "EPISODE"
else -> item.type.uppercase(Locale.US).ifBlank { "LIBRARY" }
}
@@ -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,455 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.genre
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
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.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.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material.icons.filled.MilitaryTech
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.SportsSoccer
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun GenreDiscoveryStrip(
itemType: String,
navigationFocusRequester: FocusRequester,
entryFocusRequester: FocusRequester,
onFocused: () -> Unit,
onOpenCategory: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val categories = remember(itemType) { genreCategoryTabs(itemType) }
Column(modifier.padding(vertical = 4.dp)) {
Text(
"Browse genres",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
GenreDiscoveryCard(
category = category,
onFocused = onFocused,
onClick = { onOpenCategory(category.id) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.focusProperties { if (index == 0) left = navigationFocusRequester },
)
}
}
}
}
@Composable
private fun GenreDiscoveryCard(
category: GenreCategory,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val visual = remember(category.icon) { genreVisual(category.icon) }
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Browse ${category.label}",
modifier = modifier.width(142.dp),
) { focused ->
Column {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.background(visual.colour, RoundedCornerShape(MembyCardCorner))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center,
) {
Icon(
visual.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(48.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
category.label,
color = if (focused) Color.White else MembyMutedText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private data class GenreVisual(val icon: ImageVector, val colour: Color)
private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
GenreCategoryIcon.ALL -> GenreVisual(Icons.Default.Category, Color(0xFF4F46A5))
GenreCategoryIcon.ACTION -> GenreVisual(Icons.Default.Bolt, Color(0xFFB45309))
GenreCategoryIcon.COMEDY -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF15803D))
GenreCategoryIcon.CRIME -> GenreVisual(Icons.Default.Gavel, Color(0xFF475569))
GenreCategoryIcon.DRAMA -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF7E22CE))
GenreCategoryIcon.HORROR -> GenreVisual(Icons.Default.LocalFireDepartment, Color(0xFF991B1B))
GenreCategoryIcon.MYSTERY -> GenreVisual(Icons.AutoMirrored.Filled.HelpOutline, Color(0xFF4338CA))
GenreCategoryIcon.SCI_FI -> GenreVisual(Icons.Default.AutoAwesome, Color(0xFF0369A1))
GenreCategoryIcon.THRILLER -> GenreVisual(Icons.Default.VisibilityOff, Color(0xFF0F766E))
GenreCategoryIcon.WAR -> GenreVisual(Icons.Default.MilitaryTech, Color(0xFF57534E))
GenreCategoryIcon.FAMILY -> GenreVisual(Icons.Default.SentimentVerySatisfied, Color(0xFFDB2777))
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(Icons.Default.VideoLibrary, Color(0xFF0E7490))
GenreCategoryIcon.ROMANCE -> GenreVisual(Icons.Default.Favorite, Color(0xFFBE185D))
GenreCategoryIcon.WESTERN -> GenreVisual(Icons.Default.Landscape, Color(0xFF92400E))
GenreCategoryIcon.MUSIC -> GenreVisual(Icons.Default.MusicNote, Color(0xFF6D28D9))
GenreCategoryIcon.SPORT -> GenreVisual(Icons.Default.SportsSoccer, Color(0xFF047857))
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
}
@Composable
fun GenreBrowseScreen(
itemType: String,
initialCategoryId: String,
favouriteStates: Map<String, Boolean>,
playedStates: Map<String, Boolean>,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV shows" else "movies"
val browseViewModel: GenreBrowseViewModel = viewModel(
key = "genre-browse-${itemType.lowercase()}",
factory = remember(itemType) {
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
},
)
val state by browseViewModel.state.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
val tabState = androidx.compose.foundation.lazy.rememberLazyListState()
val selectedCategory = remember(state.selectedCategoryId, itemType) {
genreCategory(itemType, state.selectedCategoryId)
}
LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
LaunchedEffect(state.selectedCategoryId) {
val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0)
if (state.selectedCategoryId != null) {
gridState.scrollToItem(0)
tabState.scrollToItem(index)
}
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(32L)
runCatching { contentFocusRequester.requestFocus() }
}
BackHandler(onBack = onClose)
BoxWithConstraints(modifier.fillMaxSize().background(MembySurface)) {
val columns = when {
maxWidth >= 1080.dp -> 7
maxWidth >= 860.dp -> 6
maxWidth >= 680.dp -> 5
else -> 4
}
val spacing = 16.dp
val horizontalPadding = 36.dp
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
.coerceAtLeast(112.dp)
Column(Modifier.fillMaxSize().padding(top = 24.dp)) {
Row(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
FocusScaleContainer(
onFocused = {},
onClick = onClose,
contentDescription = "Back to $mediaLabel",
modifier = Modifier
.size(42.dp)
.focusProperties { left = navigationFocusRequester }
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)),
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = if (focused) MembySurface else Color.White,
modifier = Modifier.size(22.dp),
)
}
}
Spacer(Modifier.width(14.dp))
Column {
Text(
"Browse $mediaLabel",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
selectedCategory.label,
color = MembyMutedText,
fontSize = 14.sp,
)
}
}
Spacer(Modifier.height(16.dp))
LazyRow(
state = tabState,
contentPadding = PaddingValues(horizontal = horizontalPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
val selected = category.id == state.selectedCategoryId
FocusScaleContainer(
onFocused = {},
onClick = {
if (!selected) {
browseViewModel.selectCategory(category.id)
}
},
contentDescription = "Browse ${category.label}",
modifier = Modifier
.then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier)
.focusProperties { if (index == 0) left = navigationFocusRequester }
.background(
when {
selected -> MembyAccent
else -> MembySurfaceRaised.copy(alpha = 0.72f)
},
RoundedCornerShape(18.dp),
)
.border(
1.dp,
if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f),
RoundedCornerShape(18.dp),
),
) { focused ->
Text(
category.label,
color = when {
focused -> MembySurface
selected -> Color.White
else -> MembyMutedText
},
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
modifier = Modifier
.background(
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(18.dp),
)
.padding(horizontal = 17.dp, vertical = 9.dp),
)
}
}
}
Spacer(Modifier.height(18.dp))
when {
state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel")
state.isLoading && state.items.isEmpty() -> GenreMessage(
"Loading ${selectedCategory.label}",
)
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
message = state.errorMessage.orEmpty(),
onRetry = browseViewModel::retry,
)
state.items.isEmpty() -> GenreMessage(
if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
)
else -> {
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
.distinctUntilChanged()
.collect { last ->
if (state.canLoadMore && last >= state.items.size - columns * 2) {
browseViewModel.loadMore()
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
contentPadding = PaddingValues(
start = horizontalPadding,
end = horizontalPadding,
bottom = 72.dp,
),
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(
state.items,
key = { _, item -> item.id },
contentType = { _, _ -> "genre-poster" },
) { index, item ->
val favourite = favouriteStates[item.id]
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
item
} else {
item.withFavourite(favourite)
}
PosterGridCard(
item = displayedItem,
width = cardWidth,
onFocused = { onItemFocused(displayedItem) },
onClick = { onItemSelected(displayedItem) },
onLongClick = { onItemSelected(displayedItem) },
modifier = Modifier
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
.focusProperties {
if (index % columns == 0) left = navigationFocusRequester
},
)
}
if (state.isLoadingMore) {
item(span = { GridItemSpan(maxLineSpan) }) {
GenreMessage("Loading more…")
}
} else if (state.errorMessage != null) {
item(span = { GridItemSpan(maxLineSpan) }) {
GenreRetry(
message = state.errorMessage.orEmpty(),
onRetry = browseViewModel::retry,
)
}
}
}
}
}
}
}
}
@Composable
private fun GenreMessage(message: String) {
Box(Modifier.fillMaxWidth().padding(36.dp), contentAlignment = Alignment.Center) {
Text(message, color = MembyMutedText, fontSize = 15.sp)
}
}
@Composable
private fun GenreRetry(message: String, onRetry: () -> Unit) {
Column(
Modifier.fillMaxWidth().padding(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(message, color = MembyMutedText, fontSize = 15.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
FocusScaleContainer(
onFocused = {},
onClick = onRetry,
contentDescription = "Try loading the genre again",
modifier = Modifier
.background(MembyAccent, RoundedCornerShape(9.dp))
.semantics { contentDescription = "Try again" },
) { focused ->
Text(
"Try again",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}
}
}
@@ -0,0 +1,183 @@
package com.ponzischeme89.memby.ui.genre
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null,
val items: List<BaseItem> = emptyList(),
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = false,
val errorMessage: String? = null,
)
/** A paged, media-type-specific genre shelf used by the Movies and TV Series pages. */
class GenreBrowseViewModel(
private val repository: EmbyRepository,
private val itemType: String,
) : ViewModel() {
private val categories = genreCategoryTabs(itemType)
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
fun selectCategory(categoryId: String) {
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
val current = state.value
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
val cached = categoryPages[selected.id]
pageJob?.cancel()
_state.update {
it.copy(
selectedCategoryId = selected.id,
items = cached?.items.orEmpty(),
isLoading = cached == null,
isLoadingMore = false,
canLoadMore = cached?.canLoadMore ?: false,
errorMessage = null,
)
}
if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) }
}
fun loadMore() {
val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
_state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(category, current.items.size) }
}
fun retry() {
val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
val offset = current.items.size
_state.update {
it.copy(
isLoading = offset == 0,
isLoadingMore = offset > 0,
errorMessage = null,
)
}
pageJob?.cancel()
pageJob = viewModelScope.launch { loadPage(category, offset) }
}
/** Applies optimistic favourite changes made on a detail page to this screen's copies. */
fun applyFavouriteStates(changes: Map<String, Boolean>) {
if (changes.isEmpty()) return
categoryPages.entries.forEach { entry ->
entry.setValue(
entry.value.copy(
items = entry.value.items.map { item ->
changes[item.id]?.let { item.withFavourite(it) } ?: item
},
),
)
}
_state.update { current ->
current.copy(
items = current.items.map { item ->
val favourite = changes[item.id] ?: return@map item
item.withFavourite(favourite)
},
)
}
}
/** Applies optimistic watched changes to the paged copies without losing position. */
fun applyPlayedStates(changes: Map<String, Boolean>) {
if (changes.isEmpty()) return
categoryPages.entries.forEach { entry ->
entry.setValue(
entry.value.copy(
items = entry.value.items.map { item ->
changes[item.id]?.let { item.withPlayed(it) } ?: item
},
),
)
}
_state.update { current ->
current.copy(
items = current.items.map { item ->
val played = changes[item.id] ?: return@map item
item.withPlayed(played)
},
)
}
}
private suspend fun loadPage(category: GenreCategory, offset: Int) {
runCatching {
if (category.genres.isEmpty()) {
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
} else {
repository.browseGenre(
genre = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
}
}.onSuccess { page ->
_state.update { current ->
if (current.selectedCategoryId != category.id || current.items.size != page.offset) {
return@update current
}
val items = (current.items + page.items).distinctBy(BaseItem::id)
val canLoadMore = hasMoreGenreItems(
loaded = items.size,
total = page.total,
lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE,
)
categoryPages[category.id] = CachedCategoryPage(items, canLoadMore)
current.copy(
items = items,
isLoading = false,
isLoadingMore = false,
canLoadMore = canLoadMore,
errorMessage = null,
)
}
}.onFailure { error ->
_state.update { current ->
if (current.selectedCategoryId != category.id) current else current.copy(
isLoading = false,
isLoadingMore = false,
canLoadMore = false,
errorMessage = friendlyEmbyError(error),
)
}
}
}
}
private data class CachedCategoryPage(
val items: List<BaseItem>,
val canLoadMore: Boolean,
)
class GenreBrowseViewModelFactory(
private val repository: EmbyRepository,
private val itemType: String,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
GenreBrowseViewModel(repository, itemType) as T
}
@@ -0,0 +1,111 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
/**
* A stable, human-sized genre catalogue.
*
* Emby libraries tend to describe the same shelf with several neighbouring labels
* (Action and Adventure, Science Fiction and Sci-Fi, Family and Animation). Presenting
* every raw label makes the picker long and unpredictable, so the television exposes a
* fixed set of useful categories and sends their pipe-delimited Emby genres as one OR
* filter. The order is product design, not server data, and therefore never jumps around
* while home rows are arriving.
*/
data class GenreCategory(
val id: String,
val label: String,
val genres: List<String>,
val icon: GenreCategoryIcon,
) {
val filter: String get() = genres.joinToString("|")
}
enum class GenreCategoryIcon {
ALL,
ACTION,
COMEDY,
CRIME,
DRAMA,
HORROR,
MYSTERY,
SCI_FI,
THRILLER,
WAR,
FAMILY,
DOCUMENTARY,
ROMANCE,
WESTERN,
MUSIC,
SPORT,
REALITY,
}
const val ALL_MEDIA_CATEGORY_ID = "all"
private val allMediaCategory = GenreCategory(
id = ALL_MEDIA_CATEGORY_ID,
label = "All",
genres = emptyList(),
icon = GenreCategoryIcon.ALL,
)
private val coreGenreCategories = listOf(
GenreCategory("action-adventure", "Action & Adventure", listOf("Action", "Adventure"), GenreCategoryIcon.ACTION),
GenreCategory("comedy", "Comedy", listOf("Comedy"), GenreCategoryIcon.COMEDY),
GenreCategory("crime", "Crime", listOf("Crime", "Film-Noir"), GenreCategoryIcon.CRIME),
GenreCategory("drama", "Drama", listOf("Drama"), GenreCategoryIcon.DRAMA),
GenreCategory("horror", "Horror", listOf("Horror"), GenreCategoryIcon.HORROR),
GenreCategory("mystery", "Mystery", listOf("Mystery"), GenreCategoryIcon.MYSTERY),
GenreCategory(
"sci-fi-fantasy",
"Sci-Fi & Fantasy",
listOf("Science Fiction", "Sci-Fi", "Sci Fi", "Fantasy"),
GenreCategoryIcon.SCI_FI,
),
GenreCategory("thriller", "Thriller", listOf("Thriller", "Suspense"), GenreCategoryIcon.THRILLER),
GenreCategory("war-history", "War & History", listOf("War", "History"), GenreCategoryIcon.WAR),
GenreCategory(
"family-animation",
"Family & Animation",
listOf("Family", "Animation", "Children", "Kids"),
GenreCategoryIcon.FAMILY,
),
GenreCategory("documentary", "Documentary", listOf("Documentary"), GenreCategoryIcon.DOCUMENTARY),
GenreCategory("romance", "Romance", listOf("Romance"), GenreCategoryIcon.ROMANCE),
GenreCategory("western", "Western", listOf("Western"), GenreCategoryIcon.WESTERN),
GenreCategory("music-musicals", "Music & Musicals", listOf("Music", "Musical"), GenreCategoryIcon.MUSIC),
GenreCategory("sport", "Sport", listOf("Sport", "Sports"), GenreCategoryIcon.SPORT),
)
private val realityCategory = GenreCategory(
"reality",
"Reality TV",
listOf("Reality", "Reality TV"),
GenreCategoryIcon.REALITY,
)
fun genreCategories(itemType: String): List<GenreCategory> =
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
else coreGenreCategories
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType)
fun genreCategory(itemType: String, id: String?): GenreCategory =
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
fun allMediaLabel(itemType: String): String =
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
)
fun BaseItem.withPlayed(played: Boolean): BaseItem = copy(
userData = (userData ?: UserItemData()).copy(
played = played,
playbackPositionTicks = if (played) 0L else userData?.playbackPositionTicks ?: 0L,
),
)
@@ -0,0 +1,185 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
data class CastMember(
val name: String,
val role: String? = null,
val imageUrl: String? = null,
)
/**
* What the panel is showing.
*
* [loaded] is separate from an empty [members] list because "still fetching" and "this
* title has no cast recorded" are different things to be told, and a spinner that never
* resolves is the worse of the two to leave on screen.
*/
data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(),
val loaded: Boolean = false,
)
/**
* Fills the cast overlay from a plain state.
*
* Like [bindSubtitleMenu], it lives apart from [PlayerActivity] so `CastPanelScreenshotTest`
* can render the real panel these cards, these colours, this layout with no player, no
* Emby server and no network. [loadImage] is injected for the same reason: artwork is the
* one thing a screenshot test cannot fetch, and passing it in means the test renders the
* initials fallback rather than a blank where every portrait should be.
*/
fun bindCastPanel(
overlay: View,
state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> },
) {
val context = overlay.context
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
text = state.title
isVisible = state.title.isNotBlank()
}
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
text = when {
!state.loaded -> context.getString(R.string.player_cast_loading)
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
else -> ""
}
isVisible = !text.isNullOrEmpty()
}
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
people.removeAllViews()
state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) }
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty()
}
private fun castCard(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
): View {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
isFocusable = true
isClickable = true
// Nothing happens on a press. The panel is a reference, not a destination — there
// is no person page to open — but the card still has to be focusable, or a D-pad
// cannot scroll the row at all.
setOnClickListener { }
clipChildren = false
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(18)
}
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.06f else 1f)
.scaleY(if (focused) 1.06f else 1f)
.setDuration(120L)
.start()
}
addView(castPortrait(context, member, loadImage, ::dp))
addView(
TextView(context).apply {
text = member.name
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(9), 0, 0)
},
)
member.role?.takeIf(String::isNotBlank)?.let { role ->
addView(
TextView(context).apply {
text = role
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(2), 0, 0)
},
)
}
}
}
/**
* The portrait, with the person's initials underneath it.
*
* Emby has no photo for a good part of a typical cast, and a row of identical grey
* rectangles tells a viewer nothing about which name is which. The initials sit *behind*
* the image rather than replacing it, so nothing has to decide in advance whether the
* artwork will arrive when it does, it simply covers them.
*/
private fun castPortrait(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(132), dp(176))
background = context.getDrawable(R.drawable.player_cast_portrait_background)
// The ring is drawn over the artwork, and the frame follows the card's focus rather
// than its own — the card is what takes focus, the portrait is never focusable itself.
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
isDuplicateParentStateEnabled = true
clipToOutline = true
addView(
TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
text = castInitials(member.name)
setTextColor(Color.rgb(126, 138, 148))
textSize = 34f
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
gravity = Gravity.CENTER
},
)
addView(
ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = member.name
member.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
},
)
}
/**
* Up to two initials from a name. Pure, so it can be tested without a view: the cases that
* matter are a mononym, a name with a middle name (the *last* initial is the useful one,
* not the second) and a blank, which yields nothing rather than a stray character.
*/
internal fun castInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank)
return when (parts.size) {
0 -> ""
1 -> parts[0].take(1).uppercase()
else -> (parts.first().take(1) + parts.last().take(1)).uppercase()
}
}
@@ -0,0 +1,125 @@
package com.ponzischeme89.memby.ui.player
/**
* How fast the closing credits run, and what to do when the stream cannot keep up.
*
* Pure and unit-tested, apart from the player, for the usual reason the rest of this package
* splits that way: the arithmetic is the part that can be wrong in a way nobody would notice
* on a television, and the part a screenshot cannot show.
*
* The fallback is the half worth reading. Doubling the speed doubles the bitrate pulled from
* Emby over HTTP, and a remuxed 4K file on a remote server may simply not sustain it so
* the target is a *ceiling that can fall*, never a speed that is set once and defended. It
* steps down on a stall and never climbs back inside the same credit roll: a stream that
* could not hold 2× thirty seconds ago is not one to keep testing over somebody's picture,
* and a speed that oscillated would be worse than either end of it.
*/
/**
* Where the picture goes while the panel has the right half: a little under half size, shifted
* left by a little under a quarter of the width.
*
* The two are a pair and neither is a round number, which is the point. A clean 0.5 and 0.25
* put the picture's left edge at exactly x=0 mathematically the left half, and on a
* television the first thing overscan cuts. Shrinking it slightly buys an inset at that edge
* *and* clearance against the panel's own padding, so the gap between the credits and the
* words about the next episode is deliberate rather than whatever was left over.
*
* They live here rather than privately in `PlayerActivity` so `EndCreditsScreenshotTest` can
* place its stand-in picture at exactly the transform the activity applies; a capture that
* guessed the split would prove nothing about whether the two halves balance.
*/
const val CREDITS_VIDEO_SCALE = 0.45f
const val CREDITS_VIDEO_SHIFT_X = 0.24f
/** Ordinary speed, and the floor the ceiling falls to. */
const val CREDITS_NORMAL_SPEED = 1.0f
/** What the credits aim for. */
const val CREDITS_TARGET_SPEED = 2.0f
/**
* The ceilings, in the order they are given up.
*
* Three rather than a continuous climbdown: each step has to be held long enough to know
* whether it worked, and a stream that stalls at 1.5× is telling us to stop rather than to
* try 1.4×.
*/
val CREDITS_SPEED_STEPS: List<Float> = listOf(2.0f, 1.5f, CREDITS_NORMAL_SPEED)
/**
* How long the picture takes to reach full speed.
*
* Long enough to hear as a ramp rather than a glitch the music speeding up is most of what
* tells a viewer the player did this deliberately and short enough that it is over before
* anybody reaches for the remote.
*/
const val CREDITS_RAMP_MS = 1_500L
/**
* How many quantisation steps make up 1× so the smallest change sent to the player is
* 0.05×.
*
* Every call rebuilds the audio pipeline's resampler, so a ramp asking for 1.8001× after
* 1.8000× is work for nothing. Quantising also makes the ramp reproducible in a test.
*/
private const val CREDITS_SPEED_STEPS_PER_UNIT = 20f
/**
* The speed at [elapsedMs] into a ramp towards [ceiling].
*
* Eased out rather than linear, the same curve `DecelerateInterpolator` gives every other
* transition in this package: most of the change happens immediately, so the effect reads as
* the picture being let go rather than as a slow drift nobody attributes to anything.
*
* A ceiling at or below normal speed is answered with normal speed at every point, including
* zero a ramp to nowhere must not produce a curve.
*/
fun creditsSpeedAt(elapsedMs: Long, ceiling: Float): Float {
if (ceiling <= CREDITS_NORMAL_SPEED) return CREDITS_NORMAL_SPEED
if (elapsedMs <= 0L) return CREDITS_NORMAL_SPEED
if (elapsedMs >= CREDITS_RAMP_MS) return quantiseSpeed(ceiling)
val progress = elapsedMs.toFloat() / CREDITS_RAMP_MS
val eased = 1f - (1f - progress) * (1f - progress)
return quantiseSpeed(CREDITS_NORMAL_SPEED + (ceiling - CREDITS_NORMAL_SPEED) * eased)
}
/**
* The next ceiling down after the stream failed to hold [ceiling].
*
* Returns [CREDITS_NORMAL_SPEED] once there is nowhere left to fall, which is also the
* signal to stop trying: the caller treats reaching the floor as the end of the ramp rather
* than as another step to schedule.
*/
fun creditsCeilingAfterStall(ceiling: Float): Float {
val next = CREDITS_SPEED_STEPS.firstOrNull { it < ceiling - SPEED_EPSILON }
return next ?: CREDITS_NORMAL_SPEED
}
/** Whether a ceiling still has any speeding up left in it. */
fun creditsSpeedIsActive(ceiling: Float): Boolean = ceiling > CREDITS_NORMAL_SPEED + SPEED_EPSILON
/**
* How a speed reads on the chip in the corner of the panel: "2×", "1.5×".
*
* Built out of whole tenths rather than by formatting the float. A quantised 1.5 is not
* exactly 1.5 in binary, so `toString` on it prints "1.5000001", and `String.format` would
* print a comma for the decimal point on a set configured in half of Europe.
*/
fun creditsSpeedLabel(speed: Float): String {
val tenths = Math.round(speed * 10f)
val whole = tenths / 10
val fraction = tenths % 10
return if (fraction == 0) "$whole×" else "$whole.$fraction×"
}
/**
* Rounds to the nearest step. Written as a multiply-round-divide so that the three speeds
* that matter 1.0, 1.5, 2.0 come back exactly, which is what lets a test assert the ramp
* reaches its ceiling.
*/
private fun quantiseSpeed(speed: Float): Float =
Math.round(speed * CREDITS_SPEED_STEPS_PER_UNIT) / CREDITS_SPEED_STEPS_PER_UNIT
/** Floats compared by threshold, since every one of these has been through a division. */
private const val SPEED_EPSILON = 0.001f
@@ -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
}
@@ -0,0 +1,89 @@
package com.ponzischeme89.memby.ui.player
import androidx.media3.common.PlaybackException
/**
* A viewer-facing description of a Media3 failure. Keep this independent from Activity
* state so the retry decision remains deterministic and unit-testable.
*/
internal data class PlaybackFailure(
val title: String,
val detail: String,
val canAutoRetry: Boolean,
val requiresTranscode: Boolean = false,
val requiresFreshStream: Boolean = false,
)
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
when (errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_TIMEOUT,
-> PlaybackFailure(
title = "Connection interrupted",
detail = "Memby couldnt keep a reliable connection to the media server.",
canAutoRetry = true,
)
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
-> PlaybackFailure(
title = "Video unavailable",
detail = "The media server couldnt provide this video. It may have moved or be temporarily unavailable.",
canAutoRetry = true,
requiresFreshStream = true,
)
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
-> PlaybackFailure(
title = "Playback blocked",
detail = "The TV is not permitted to open this stream. Check the server address and access settings.",
canAutoRetry = false,
)
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
-> PlaybackFailure(
title = "Video format not supported",
detail = "This TV couldnt decode the original format. Memby will request a compatible stream.",
canAutoRetry = true,
requiresTranscode = true,
)
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
-> PlaybackFailure(
title = "Video file couldnt be read",
detail = "This TV couldnt 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 couldnt be read",
detail = "The media server returned a malformed stream.",
canAutoRetry = false,
)
else -> PlaybackFailure(
title = "Playback stopped",
detail = "Memby hit an unexpected playback problem.",
canAutoRetry = false,
)
}
internal fun automaticRetryDelayMs(attempt: Int): Long? =
when (attempt) {
1 -> 1_000L
2 -> 3_000L
else -> null
}
@@ -0,0 +1,69 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.PlaybackSession
/**
* A persisted final check-in. WorkManager keeps this request across process death, so
* Back, task removal and background process eviction all converge on the same Emby stop.
*/
class PlaybackStopWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
ServiceLocator.init(applicationContext)
val itemId = inputData.getString(ITEM_ID).orEmpty()
if (itemId.isBlank()) return Result.failure()
val session = PlaybackSession(
itemId = itemId,
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
playSessionId = inputData.getString(PLAY_SESSION_ID).orEmpty(),
playMethod = inputData.getString(PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
)
return runCatching {
ServiceLocator.repository.reportPlaybackStopped(
session,
inputData.getLong(POSITION_MS, 0L),
)
}.fold(
onSuccess = { Result.success() },
onFailure = { if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure() },
)
}
companion object {
private const val ITEM_ID = "item_id"
private const val MEDIA_SOURCE_ID = "media_source_id"
private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms"
private const val MAX_RETRIES = 5
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
val data = Data.Builder()
.putString(ITEM_ID, session.itemId)
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
.putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data)
.build()
val key = session.playSessionId.ifBlank { session.itemId }
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
"emby-playback-stop-$key",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -0,0 +1,67 @@
package com.ponzischeme89.memby.ui.player
/**
* Times the stages between pressing Play and seeing a frame.
*
* "Playback is slow" is not something that can be acted on: the wait is shared between
* negotiating a stream with the server, starting the activity, opening the file over HTTP
* and initialising a decoder, and on a weak television any one of them can dominate.
* Cumulative marks make the log say which.
*
* Marks are cumulative from the moment the viewer pressed Play the interesting number is
* always "how long had they been waiting", not how long one step took in isolation and
* [summary] prints the per-stage deltas beside them so both readings are available. Pure
* and clock-injected so the arithmetic is unit-testable; [PlayerActivity] supplies
* `SystemClock.elapsedRealtime`.
*/
internal class PlaybackTrace(
private val startedAtMs: Long,
private val clock: () -> Long,
) {
private val marks = LinkedHashMap<String, Long>()
/** Records [stage] at the current time and returns milliseconds since Play was pressed. */
fun mark(stage: String): Long {
val elapsed = (clock() - startedAtMs).coerceAtLeast(0L)
// A stage only ever happens once per launch; a repeat is a retry, and the first
// time the viewer reached that point is the one that describes their wait.
marks.putIfAbsent(stage, elapsed)
return marks.getValue(stage)
}
fun elapsedMs(): Long = (clock() - startedAtMs).coerceAtLeast(0L)
/**
* One line naming every stage reached, as `stage=cumulative(+delta)`. Stages that never
* happened are simply absent rather than reported as zero on the resume path there is
* no pre-roll, and a launch that reused a prefetched stream never resolved one.
*/
fun summary(): String {
var previous = 0L
return marks.entries.joinToString(" ") { (stage, elapsed) ->
val delta = (elapsed - previous).coerceAtLeast(0L)
previous = elapsed
"$stage=${elapsed}ms(+${delta}ms)"
}
}
companion object {
/** The viewer's Play press has been handled and the player activity is alive. */
const val ACTIVITY_CREATED = "activity"
/** The ExoPlayer instance exists and its renderers are built. */
const val PLAYER_BUILT = "player"
/** A stream URL is in hand, whether from the launch prefetch or a fresh request. */
const val STREAM_RESOLVED = "stream"
/** [androidx.media3.exoplayer.ExoPlayer.prepare] has been called. */
const val PREPARED = "prepared"
/** The player reported it has buffered enough to play. */
const val READY = "ready"
/** A frame is on the screen. This is the number the viewer actually experiences. */
const val FIRST_FRAME = "first_frame"
}
}
@@ -0,0 +1,49 @@
package com.ponzischeme89.memby.ui.player
import androidx.tracing.Trace
import java.util.concurrent.atomic.AtomicInteger
/**
* Names the two spans of a playback launch in a systrace, so `:benchmark` can measure
* what [PlaybackTrace] can only log.
*
* The log line is for reading after the fact; these are for a machine that has to compare
* a hundred launches and tell you whether a change made things worse. They measure the same
* two things, from the same three points in [PlayerActivity], which is why the names live
* here beside each other rather than being typed into a benchmark and a player separately
* `:benchmark` imports [LAUNCH] and [FIRST_FRAME] by reference, so a rename cannot quietly
* turn the benchmark into one that measures nothing and reports zero.
*
* These are *async* sections deliberately: a launch begins on the main thread in `onCreate`
* and ends in a media3 callback, and a synchronous begin/end pair cannot span that.
*
* Everything here is a no-op unless a trace is being captured, so this costs a released
* build an atomic increment and a boolean check per playback.
*/
internal object PlaybackTraceSections {
/** The viewer pressed Play until there is a picture. What they actually experience. */
const val LAUNCH = "Memby.playbackLaunch"
/**
* `prepare()` until there is a picture: opening the file over HTTP, seeking to the
* resume point, and initialising a decoder. Measured separately because it is by far
* the largest share of a resume, and the one that a change to the player's HTTP stack
* or buffering would move.
*/
const val FIRST_FRAME = "Memby.playbackFirstFrame"
private val cookies = AtomicInteger()
/** A cookie distinguishing overlapping sections; auto-advance can start one while the
* previous episode's is still open. */
fun nextCookie(): Int = cookies.incrementAndGet()
fun begin(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.beginAsyncSection(name, cookie)
}
fun end(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.endAsyncSection(name, cookie)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
/**
* Builds the one ExoPlayer this app plays video with.
*
* It lives apart from [PlayerActivity] because construction is on the critical path of
* every launch and wants to happen before any of the activity's decoration: the activity
* builds this first, hands it the stream, and only then wires up its overlays.
*
* The configuration here is aimed squarely at *time to first frame on a resume*, which is
* the slowest thing the player does a resume is a seek, and a seek over HTTP is several
* more requests before a single frame is decoded. Two of the choices are borrowed from
* Wholphin (https://github.com/damontecres/Wholphin), an Android TV Jellyfin client under
* the same GPL-2.0 licence as this app: constant-bitrate seeking, and pulling media bytes
* through the app's own OkHttp client rather than media3's default.
*/
@UnstableApi
internal object PlayerEngine {
fun create(context: Context): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory(
DefaultRenderersFactory(context)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true),
)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
.build()
.apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* handleAudioFocus = */ false,
)
}
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
// only the HTTP half is swapped for ours.
DefaultDataSource.Factory(context, OkHttpDataSource.Factory(streamClient)),
extractorsFactory(),
)
/**
* Derived from the shared stack, so the stream, the artwork and the API calls share one
* connection pool. That is what a resume is really paying for: opening a file part-way
* through means a request for the container header, usually another for the seek index
* (in a Matroska file the cues commonly sit at the *end*), and then a third at the
* offset the viewer actually stopped at. On the default client each of those is a fresh
* connection and over HTTPS, a fresh TLS handshake before any bytes arrive.
*
* The read timeout is generous rather than absent: a stalled stream must eventually
* fail so [PlaybackRecovery] can re-negotiate it, but it must not fail on a slow seek.
* No call timeout is set, and none should be that would cap the length of the film.
*/
private val streamClient by lazy {
HttpStack.base.newBuilder()
.connectTimeout(STREAM_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(STREAM_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Constant-bitrate seeking is the difference between a resume that is a byte-offset
* calculation and one that is a scan through the file. Media3 only uses it for
* containers with no seek table of their own, so switching it on cannot make an indexed
* MP4 or MKV any less accurate; it only rescues the formats that would otherwise have
* to read their way to the position. `AlwaysEnabled` extends that to streams that
* declare a seek table but cannot use it.
*/
private fun extractorsFactory() = DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun loadControl() = DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS,
MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_AFTER_REBUFFER_MS,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
private const val STREAM_CONNECT_TIMEOUT_SECONDS = 15L
private const val STREAM_READ_TIMEOUT_SECONDS = 30L
private const val MIN_BUFFER_MS = 10_000
private const val MAX_BUFFER_MS = 30_000
private const val BUFFER_FOR_PLAYBACK_MS = 750
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
}
@@ -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)
}
}
@@ -0,0 +1,100 @@
package com.ponzischeme89.memby.ui.player
import kotlin.math.abs
/**
* The arithmetic and the wording behind Left/Right skipping, kept apart from
* [PlayerActivity] so it can be unit-tested without a player, a decoder or a server.
*
* The shape that matters is [SeekPreview]: a press does not seek. It moves a *target* that
* the OSD renders, and the seek is committed once the presses stop. Seeking over HTTP
* costs several requests before a frame is decoded (see "Time to first frame" in
* CLAUDE.md), so four quick presses of Right must be one seek of two minutes rather than
* four seeks the viewer waits through in turn which is also what makes the number on
* screen honest while they are still pressing.
*/
/**
* The last second of a title is not a place to land: seeking there ends playback, which on
* an episode rolls into the next one. Fast-forward stops short of the end instead.
*/
private const val SEEK_END_GUARD_MS: Long = 1_000L
/**
* Where the next commit will land, and how far that is from where the burst of presses
* started. [offsetMs] is deliberately cumulative rather than one step: the viewer is
* reading a running total ("Forward 1 min 30 secs"), not the last button they pressed.
*/
data class SeekPreview(val targetMs: Long, val offsetMs: Long)
/**
* Adds one step to an in-flight preview, or starts one from the playhead.
*
* [previous] is the base when there is one, never the live position: the film keeps
* playing while somebody holds down Right, and measuring each step from the playhead
* would quietly swallow part of every press after the first.
*/
fun accumulateSeek(
previous: SeekPreview?,
positionMs: Long,
durationMs: Long,
stepMs: Long,
forward: Boolean,
): SeekPreview {
val origin = previous?.let { it.targetMs - it.offsetMs } ?: positionMs.coerceAtLeast(0L)
val base = previous?.targetMs ?: positionMs.coerceAtLeast(0L)
val step = if (forward) stepMs else -stepMs
val target = clampSeekTarget(base + step, durationMs)
return SeekPreview(targetMs = target, offsetMs = target - origin)
}
/** Bounds a target to the title: never before the start, never onto its final second. */
fun clampSeekTarget(targetMs: Long, durationMs: Long): Long {
if (durationMs <= 0L) return targetMs.coerceAtLeast(0L)
val ceiling = (durationMs - SEEK_END_GUARD_MS).coerceAtLeast(0L)
return targetMs.coerceIn(0L, ceiling)
}
/**
* "30 seconds", "1 min 30 secs" the amount alone, with no sign. Direction is said by the
* glyph and by the wording around it ("Back …" / "Forward …"), because a minus sign read
* across a room at the size this is drawn is not a difference anybody can see.
*/
fun seekAmountLabel(offsetMs: Long): String {
val totalSeconds = (abs(offsetMs) + 500L) / 1_000L
val minutes = totalSeconds / 60L
val seconds = totalSeconds % 60L
return when {
minutes == 0L -> if (seconds == 1L) "1 second" else "$seconds seconds"
seconds == 0L -> if (minutes == 1L) "1 min" else "$minutes mins"
else -> "${if (minutes == 1L) "1 min" else "$minutes mins"} " +
if (seconds == 1L) "1 sec" else "$seconds secs"
}
}
/** Clock for the OSD: hours only when the title has them, so a 42-minute episode reads short. */
fun formatSeekClock(positionMs: Long): String {
val totalSeconds = positionMs.coerceAtLeast(0L) / 1_000L
val hours = totalSeconds / 3_600L
val minutes = (totalSeconds % 3_600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%d:%02d".format(minutes, seconds)
}
}
/**
* "12:34 / 1:45:00". Both halves are formatted against the *duration*, so a position under
* an hour in a two-hour film still reads with the hours field its total has.
*/
fun seekPositionLabel(targetMs: Long, durationMs: Long): String {
if (durationMs <= 0L) return formatSeekClock(targetMs)
val position = if (durationMs >= 3_600_000L && targetMs < 3_600_000L) {
"0:" + formatSeekClock(targetMs).padStart(5, '0')
} else {
formatSeekClock(targetMs)
}
return "$position / ${formatSeekClock(durationMs)}"
}
@@ -0,0 +1,144 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.R
import kotlin.math.ceil
import kotlin.math.min
/**
* The ring on the skip-intro button: how long is left to press it, drawn as a draining arc
* with the figure inside.
*
* Like [PrerollCountdownView] it runs no animator of its own. PlayerActivity advances it
* from the playhead, which is what keeps it honest pausing during the titles holds the
* ring where it is, and seeking moves it to wherever the film now is, neither of which a
* timer counting wall-clock seconds could do.
*
* It takes its colours from its own drawable state rather than from a setter. The button
* around it is a state-list pill green with white text, white with dark text once
* focused so the ring has to change with it or it disappears into the fill the moment
* somebody's remote reaches it. `duplicateParentState` in the layout is what feeds that
* state down; without it this draws focused colours never.
*/
class SkipIntroCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create("sans-serif", Typeface.BOLD)
}
private var figure = ""
private var progress = 1f
/**
* How much of the offer is left, and how long it ran for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for two minutes at a stretch, and a two-minute ring moves by a
* fraction of a degree per tick invalidating on every one of them would be a couple
* of hundred pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
contentDescription = context.getString(R.string.player_skip_intro_countdown, nextFigure)
invalidate()
}
override fun drawableStateChanged() {
super.drawableStateChanged()
// The focused pill is white, so everything on it has to go dark — the same
// inversion `next_up_button_text` makes for the label beside this.
val focused = isFocused || drawableState.contains(android.R.attr.state_focused)
val ink = if (focused) FOCUSED_INK else Color.WHITE
// The unspent part of the ring has to stay visible without competing with the arc.
// Dark ink on the focused white pill needs more of itself than white does on green,
// where the fill is already doing half the separating.
trackPaint.color = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
)
progressPaint.color = ink
figurePaint.color = ink
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
private companion object {
val FOCUSED_INK = Color.rgb(11, 14, 17)
}
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -0,0 +1,175 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/**
* One entry in the subtitle drop-up: what it says, whether it is the choice in force, and
* whether it can be pressed. [enabled] is false while a search is running the row is
* still drawn, because removing it would move everything under the viewer's thumb, but it
* cannot take focus and so cannot start a second search.
*/
data class SubtitleMenuEntry(
val label: String,
val selected: Boolean,
val enabled: Boolean = true,
val prominent: Boolean = false,
)
/**
* The download section's whole state.
*
* [available] is the gateway's answer to "can this backend fetch a subtitle at all", and
* false hides the section rather than disabling it: a row that can never do anything is
* worse than no row. [status] is the sentence printed above the list it carries both the
* waiting message and the reason there is nothing, which are the two things a viewer
* standing in front of the set needs and cannot get anywhere else.
*/
data class SubtitleDownloadState(
val available: Boolean = false,
val status: String = "",
val entries: List<SubtitleMenuEntry> = emptyList(),
/**
* Whether the panel has switched to being *about* downloading, which hides the track
* list and the text-size chips. It is not decoration: the drop-up is 344dp wide by
* about a third of a 720p screen, and holding a track list, a size row and a list of
* search results at once squeezed the tracks down to a single visible row. One question
* at a time, and Back is what steps out of this one.
*/
val expanded: Boolean = false,
)
/**
* What the track half of the panel says when the title carries no subtitles at all.
*
* It is a state rather than an absence because the two cases read completely differently
* to somebody in front of a television. A list holding nothing but "Off" is indistinguishable
* from a menu that failed to load and the row that can do something about it sits below a
* rule, under a heading the eye has no reason to travel to. Saying it in the track section
* is what connects the two.
*
* [downloadable] is the difference between a sentence that leads somewhere and one that is
* simply the answer: on a backend with no provider there is nothing to offer, and the honest
* thing is to say so once rather than to leave the viewer looking for the option.
*/
data class SubtitleTracksState(
val empty: Boolean = false,
val downloadable: Boolean = false,
)
/**
* Fills the drop-up's containers from plain lists.
*
* It lives apart from [PlayerActivity] so `SubtitleMenuScreenshotTest` can render the real
* menu these rows, these colours, this layout without a player, an Emby server or a
* decoder, the same property that makes the detail page's content composables previewable.
* Deciding *what* the entries are stays with the activity, which is the only thing that can
* read media3's tracks and the only thing that can talk to the repository.
*/
fun bindSubtitleMenu(
overlay: View,
tracks: List<SubtitleMenuEntry>,
sizes: List<SubtitleMenuEntry>,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
tracksState: SubtitleTracksState = SubtitleTracksState(),
onTrack: (Int) -> Unit = {},
onSize: (Int) -> Unit = {},
onDownload: (Int) -> Unit = {},
) {
val context = overlay.context
val trackContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
trackContainer.removeAllViews()
sizeContainer.removeAllViews()
overlay.findViewById<TextView>(R.id.player_subtitle_empty_notice)?.apply {
isVisible = tracksState.empty
setText(
if (tracksState.downloadable) R.string.player_subtitle_none
else R.string.player_subtitle_none_unavailable,
)
}
tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
}
sizes.forEachIndexed { index, entry ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
}
bindDownloadSection(overlay, downloads, onDownload)
}
private fun bindDownloadSection(
overlay: View,
downloads: SubtitleDownloadState,
onDownload: (Int) -> Unit,
) {
val section = overlay.findViewById<View>(R.id.player_subtitle_download_section) ?: return
section.isVisible = downloads.available
// The two halves swap: expanded means the panel is about downloading and nothing else.
// The rule goes with the menu, or it draws a line under the panel's own top edge.
val expanded = downloads.available && downloads.expanded
overlay.findViewById<View>(R.id.player_subtitle_main_section).isVisible = !expanded
overlay.findViewById<View>(R.id.player_subtitle_download_rule).isVisible = !expanded
if (!downloads.available) return
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
text = downloads.status
isVisible = downloads.status.isNotBlank()
}
val container = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
container.removeAllViews()
downloads.entries.forEachIndexed { index, entry ->
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
}
}
/**
* One row of the drop-up. A [chip] sizes itself to its label for the text-size row.
* Nothing carries a tick: the current choice is the plate and the green label, and a tick on
* only some rows shifted every other label along by its width.
*/
private fun subtitleMenuOption(
context: Context,
entry: SubtitleMenuEntry,
chip: Boolean = false,
onClick: () -> Unit,
): TextView = TextView(context).apply {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46),
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
background = context.getDrawable(
if (entry.prominent) R.drawable.next_up_primary_button
else R.drawable.player_overlay_option_background,
)
if (entry.prominent) {
setTextColor(context.getColorStateList(R.color.player_overlay_primary_option_text))
} else {
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
}
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0)
text = entry.label
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 14f else 15f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs.
isFocusable = entry.enabled
isClickable = entry.enabled
alpha = if (entry.enabled) 1f else 0.45f
isSelected = entry.selected
setOnClickListener { onClick() }
}
@@ -0,0 +1,196 @@
package com.ponzischeme89.memby.ui.player
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.data.Trickplay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The frame a skip will land on, drawn into the seek indicator.
*
* The idea and the shape of it are borrowed from
* [Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin television client under
* the same GPL-2.0 licence. What differs is the format underneath: Jellyfin serves tile
* sheets and Emby serves BIF files, so where Wholphin crops a sub-image out of a grid,
* this asks for one frame's bytes (see [com.ponzischeme89.memby.data.Trickplay]).
*
* Everything here is arranged around one property: a preview must never be the reason a
* press feels slow. Nothing blocks, every failure is silent, and the thumbnail is composed
* *beside* the words rather than in place of them the chip has always said where the skip
* lands, and it still says it on a title with no previews, on a server that will not answer,
* and in the moment before the first frame arrives.
*
* There are two places a frame is drawn, because there are two ways to seek: the centred
* chip a press of Left or Right raises with the transport hidden, and the strip above the
* time bar when the controls are up and the viewer is scrubbing it. They share one instance
* deliberately one layout fetched per title and one cache of frames between them, since a
* viewer who skips and then opens the controls is travelling through the same film.
*/
class TrickplayPreview(
private val scope: CoroutineScope,
/** The layout for a title, or null when it has none. Called once per item. */
private val loadTrack: suspend (String) -> Trickplay?,
/** One thumbnail's JPEG bytes, or null. */
private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
) {
private val views = mutableListOf<ImageView>()
private var shownIn: ImageView? = null
private var itemId: String? = null
private var track: Trickplay? = null
private var trackJob: Job? = null
private var frameJob: Job? = null
private var shownFrame = NO_FRAME
/**
* The bytes of frames already fetched, rather than the bitmaps.
*
* A JPEG this size is a few kilobytes and its decoded form is a few hundred, so
* holding pixels would be most of a megabyte for a handful of thumbnails on a device
* that has better uses for it. Decoding one takes a millisecond or two, and it happens
* off the main thread anyway.
*/
private val frames = object : LinkedHashMap<Int, ByteArray>(FRAME_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, ByteArray>) =
size > FRAME_CACHE_SIZE
}
/**
* Attaches a surface once the view holding it has been inflated. Called once for the
* seek chip and once for the scrubbing strip; either may be absent.
*/
fun bind(preview: ImageView) {
if (views.none { it === preview }) views += preview
applyAspect(preview)
}
/** True when there is a layout to draw from, so a caller can leave its own chrome down. */
fun hasFrames(): Boolean = track != null
/**
* Begins on a title. [available] is the backend saying whether it will answer at all,
* so an older or deliberately-configured-off gateway is never asked once per playback.
*
* The layout is fetched now rather than on the first press, because the first press is
* exactly when it must already be there but it is fetched in the background and
* nothing waits on it.
*/
fun prepare(itemId: String, available: Boolean) {
if (this.itemId == itemId) return
reset()
this.itemId = itemId
if (!available || itemId.isBlank()) return
trackJob = scope.launch {
val resolved = loadTrack(itemId)
if (this@TrickplayPreview.itemId == itemId) track = resolved
}
}
/**
* Draws the frame covering [positionMs], if there is one.
*
* [forward] is the direction the viewer is travelling, which is used only to warm the
* frame they are most likely to ask for next. Presses come in bursts at a fixed step,
* so the one after this is a good guess and a wrong guess costs a few kilobytes.
*
* [into] names which of the bound surfaces is asking. The other is taken down rather
* than left holding a frame from the last thing that used it.
*/
fun show(positionMs: Long, forward: Boolean, into: ImageView? = views.firstOrNull()) {
val current = track ?: return
val preview = into ?: return
val frame = current.frameAt(positionMs)
if (frame == shownFrame && preview === shownIn && preview.isVisible) return
// Cancelling the previous load is the load-bearing part. Presses arrive faster than
// a fetch completes, and without this a slow response for a frame the viewer has
// already skipped past would land on screen after the one they are waiting for —
// the same reason the search pipeline uses collectLatest.
frameJob?.cancel()
frameJob = scope.launch {
val bytes = frames[frame]
?: loadFrame(current, frame)?.also { frames[frame] = it }
?: return@launch
val bitmap = decode(bytes) ?: return@launch
shownFrame = frame
shownIn = preview
applyAspect(preview)
preview.setImageBitmap(bitmap)
preview.visibility = View.VISIBLE
views.forEach { if (it !== preview) clear(it) }
warm(current, frame + if (forward) 1 else -1)
}
}
/** Takes the preview down without forgetting anything. */
fun hide() {
frameJob?.cancel()
frameJob = null
shownFrame = NO_FRAME
shownIn = null
views.forEach(::clear)
}
private fun clear(preview: ImageView) {
preview.visibility = View.GONE
preview.setImageDrawable(null)
}
/**
* Forgets the title entirely. The player advances between episodes inside the running
* player, so this is what stops one episode's thumbnails being shown over the next.
*/
fun reset() {
trackJob?.cancel()
trackJob = null
track = null
itemId = null
frames.clear()
hide()
}
private suspend fun decode(bytes: ByteArray): Bitmap? = withContext(Dispatchers.Default) {
runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
private fun warm(current: Trickplay, frame: Int) {
if (frame < 0 || frame >= current.count || frames.containsKey(frame)) return
scope.launch {
val bytes = loadFrame(current, frame) ?: return@launch
if (track === current) frames[frame] = bytes
}
}
/**
* Gives the preview the shape the frames actually are.
*
* The layout carries a 16:9 box so the chip is about the right size on the very first
* press, before anything is known; a title whose thumbnails are a wider crop would
* otherwise be letterboxed inside it for the life of the playback.
*/
private fun applyAspect(preview: ImageView) {
val current = track ?: return
val params = preview.layoutParams ?: return
val width = current.widthFor(params.height)
if (params.height <= 0 || params.width == width) return
params.width = width
preview.layoutParams = params
}
private companion object {
const val NO_FRAME = -1
/**
* A burst of presses walks through frames in one direction and a viewer often walks
* back over the same ones. Forty entries is a couple of hundred kilobytes of JPEG
* and about seven minutes of a title at ten seconds a frame.
*/
const val FRAME_CACHE_SIZE = 40
}
}
@@ -2,7 +2,7 @@ package com.ponzischeme89.memby.ui.screensaver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import androidx.core.net.toUri
/** Opens an item in Emby's installed Android or Android TV client. */ /** Opens an item in Emby's installed Android or Android TV client. */
internal object EmbyAppLauncher { internal object EmbyAppLauncher {
@@ -19,7 +19,7 @@ internal object EmbyAppLauncher {
) )
for (packageName in packageCandidates) { for (packageName in packageCandidates) {
for (link in links) { for (link in links) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) val intent = Intent(Intent.ACTION_VIEW, link.toUri())
.setPackage(packageName) .setPackage(packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) { if (intent.resolveActivity(context.packageManager) != null) {
@@ -42,9 +42,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
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
@@ -78,7 +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.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@@ -86,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
@@ -155,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,
) )
} }
@@ -169,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
@@ -198,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.
@@ -222,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
} }
@@ -242,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 }
@@ -613,11 +621,7 @@ private fun Slideshow(
) )
if (settingsOpen) { if (settingsOpen) {
SettingsSheet( SettingsSheet(onClose = { settingsOpen = false })
editableServer = true,
onClose = { settingsOpen = false },
onInstallerLaunched = onExit,
)
} }
} }
} }
@@ -625,7 +629,7 @@ private fun Slideshow(
/** A high-legibility, always-current clock beside the slide-progress indicator. */ /** A high-legibility, always-current clock beside the slide-progress indicator. */
@Composable @Composable
private fun CurrentTime(modifier: Modifier = Modifier) { private fun CurrentTime(modifier: Modifier = Modifier) {
var now by remember { mutableStateOf(System.currentTimeMillis()) } var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
now = System.currentTimeMillis() now = System.currentTimeMillis()
@@ -861,49 +865,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.getPixel(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 {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,478 @@
package com.ponzischeme89.memby.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Something to offer when there is nothing to search for yet. Kept deliberately cheap:
* both kinds are built from data the app already has in memory, because an empty search
* box is not worth a server request.
*/
data class SearchSuggestion(val label: String, val kind: Kind) {
enum class Kind { RECENT, GENRE }
}
data class SearchUiState(
val query: String = "",
val results: List<BaseItem> = emptyList(),
/**
* The genre being browsed, or null when this pane is showing a search.
*
* It is a *mode*, not a query: nothing is typed, the keyboard is untouched, and the
* results are a filtered shelf rather than matches for a word. Keeping it beside the
* query rather than pretending to be one is what lets the pane say "Comedy" instead of
* "Search results for “Comedy”", and what lets Back step out of the genre without
* clearing something the viewer never typed.
*/
val genre: String? = null,
/** A further page is on its way. The grid keeps what it has and adds a footer. */
val isLoadingMore: Boolean = false,
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */
val canLoadMore: Boolean = false,
val suggestions: List<SearchSuggestion> = emptyList(),
val isLoading: Boolean = false,
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
val hasSearched: Boolean = false,
val errorMessage: String? = null,
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
val requestMessageIsError: Boolean = false,
) {
/** The query is long enough to search but nothing came back. */
val isEmptyResult: Boolean
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
/** Nothing typed and no genre open: the pane shows discovery rather than results. */
val isDiscovery: Boolean
get() = genre == null && !shouldSearch(query)
}
/**
* Instant search.
*
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
* can never overwrite the results for "break".
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state.asStateFlow()
private val queryFlow = MutableStateFlow("")
/**
* Results for queries typed earlier in this session. Bounded and access-ordered, so
* backspacing through a word redraws instantly instead of re-querying every prefix.
*/
private val cache = object : LinkedHashMap<String, List<BaseItem>>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, List<BaseItem>>?,
): Boolean = size > CACHE_ENTRIES
}
private val recentQueries = ArrayDeque<String>()
private var genreSuggestions: List<String> = emptyList()
/**
* The page in flight, so a viewer who leaves a genre or scrolls a second page while
* the first is still coming is never overtaken by an answer to a question they have
* moved on from. It is the genre shelf's equivalent of the search pipeline's
* `collectLatest`.
*/
private var genrePageJob: Job? = null
init {
viewModelScope.launch {
queryFlow
.debounce(DEBOUNCE_MS)
.map { it.trim() }
.distinctUntilChanged()
// collectLatest rather than flatMapLatest: the search is a one-shot
// suspend call, not a flow, and this cancels the previous one on the
// same terms while keeping the repository's plain suspend signature.
.collectLatest { term -> runSearch(term) }
}
viewModelScope.launch {
repository.getRecentSearches().forEach { term ->
if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
recentQueries.addLast(term)
}
}
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
refreshSuggestions()
}
refreshSuggestions()
}
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
fun onQueryChanged(query: String) {
// Typing supersedes a genre. Somebody who reaches for the keyboard while a shelf is
// open is asking for something else, and a pane headed "Comedy" listing matches for
// what they are typing would be a lie about where those results came from.
genrePageJob?.cancel()
// The visible field updates immediately; only the *search* is debounced.
_state.update {
it.copy(
query = query,
genre = null,
isLoadingMore = false,
canLoadMore = false,
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
requestMessageIsError = false,
)
}
queryFlow.value = query
}
fun appendToQuery(text: String) = onQueryChanged(state.value.query + text)
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
genrePageJob?.cancel()
_state.update {
it.copy(
query = "", results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false, genre = null,
isLoadingMore = false, canLoadMore = false,
)
}
queryFlow.value = ""
}
/**
* A genre chip was pressed. This is a filter, not a query: nothing is typed and nothing
* is debounced the viewer made one deliberate choice and the shelf opens on it.
*/
fun onGenreSelected(genre: String) {
val name = genre.trim()
if (name.isEmpty()) return
genrePageJob?.cancel()
_state.update {
it.copy(
query = "", genre = name, results = emptyList(), isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
// The typed query is dropped along with it, and the flow is told so a stale term
// cannot arrive from the debounce and overwrite the shelf that is opening.
queryFlow.value = ""
genrePageJob = viewModelScope.launch { loadGenrePage(name, offset = 0) }
}
/** Back out of a genre, to the discovery pane the chip was pressed on. */
fun clearGenre() {
genrePageJob?.cancel()
_state.update {
it.copy(
genre = null, results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, isLoadingMore = false, canLoadMore = false,
)
}
}
/**
* The grid is near the end of what it holds. Ignored unless there is a genre open, more
* of it to fetch and nothing already in flight the grid asks on every scroll, and it
* is cheaper to refuse here than to make the screen keep track.
*/
fun loadMore() {
val current = state.value
val genre = current.genre ?: return
if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return
_state.update { it.copy(isLoadingMore = true) }
genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) }
}
/** Retry after an error, without disturbing the query or the keyboard. */
fun retry() {
val current = state.value
current.genre?.let { genre ->
genrePageJob?.cancel()
_state.update { it.copy(isLoading = true, errorMessage = null) }
genrePageJob = viewModelScope.launch {
loadGenrePage(genre, offset = current.results.size)
}
return
}
val term = current.query.trim()
if (!shouldSearch(term)) return
cache.remove(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, requestMessageIsError = false)
}
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.",
requestMessageIsError = false,
)
}
}
.onFailure { error ->
_state.update {
it.copy(
requestingCandidateKey = null,
requestMessage = friendlyEmbyError(error),
requestMessageIsError = true,
)
}
}
}
}
/**
* 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.
*/
fun setDiscoveryItems(items: List<BaseItem>) {
val genres = items.asSequence()
.flatMap { it.genres.asSequence() }
.map { it.trim() }
.filter { it.isNotEmpty() }
.groupingBy { it }
.eachCount()
.entries
.sortedByDescending { it.value }
.take(MAX_GENRE_SUGGESTIONS)
.map { it.key }
if (genres != genreSuggestions) {
genreSuggestions = genres
refreshSuggestions()
}
}
/**
* One page of a genre, appended to whatever the shelf already holds.
*
* Appending by [GenrePage.offset] rather than trusting the order of arrival is what
* makes a slow page harmless: only a page that starts where the shelf currently ends is
* taken, so a response for an offset the viewer has already scrolled past or one from
* a genre they have left is dropped rather than pasted into the middle of the grid.
*/
private suspend fun loadGenrePage(genre: String, offset: Int) {
runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
.onSuccess { page ->
_state.update { current ->
if (current.genre != genre || current.results.size != page.offset) return@update current
val items = current.results + page.items
current.copy(
results = items,
isLoading = false,
isLoadingMore = false,
hasSearched = true,
errorMessage = null,
canLoadMore = hasMoreGenreItems(
loaded = items.size,
total = page.total,
lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE,
),
)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update { current ->
if (current.genre != genre) return@update current
current.copy(
isLoading = false,
isLoadingMore = false,
hasSearched = true,
// A page that failed part way down a shelf keeps what is already
// there and simply stops: the viewer has plenty on screen, and
// replacing it with an error would take away what was working.
errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null,
canLoadMore = false,
)
}
}
}
private suspend fun runSearch(term: String) {
// A genre shelf is not a query, so the empty-query transition has nothing to say
// about it. Without this, opening a genre — which clears the query — would arrive
// here a moment later and wipe the shelf it had just filled.
if (state.value.genre != null && !shouldSearch(term)) return
if (!shouldSearch(term)) {
// Back to the discovery state, but the previous results are dropped rather
// than left behind a shorter query they no longer match.
_state.update {
it.copy(
results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
return
}
cache[term]?.let { cached ->
_state.update {
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
}
if (cached.isEmpty()) loadRequestCandidates(term)
return
}
// Previous results stay on screen underneath the spinner: a flash of empty pane
// between two letters reads as breakage, not as progress.
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.search(term) }
.onSuccess { items ->
// Gateway payloads carry the backend ranker's score. Preserve that
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
val ranked = if (items.any { it.membyRecommendationScore != null }) {
items
} else {
rankSearchResults(term, items)
}
cache[term] = ranked
rememberQuery(term)
viewModelScope.launch { repository.recordSearch(term) }
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
if (ranked.isEmpty()) loadRequestCandidates(term)
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
}
private suspend fun loadRequestCandidates(term: String) {
_state.update { it.copy(requestLookupLoading = true) }
val candidates = runCatching { repository.lookupMediaRequests(term) }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
emptyList()
}
if (_state.value.query.trim() == term) {
_state.update {
it.copy(requestCandidates = candidates, requestLookupLoading = false)
}
}
}
private fun rememberQuery(term: String) {
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
recentQueries.addFirst(term)
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
refreshSuggestions()
}
private fun refreshSuggestions() {
val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
_state.update { it.copy(suggestions = suggestions) }
}
companion object {
const val DEBOUNCE_MS = 250L
const val MIN_QUERY_LENGTH = 2
private const val CACHE_ENTRIES = 24
private const val MAX_RECENT_QUERIES = 6
private const val MAX_GENRE_SUGGESTIONS = 6
}
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
}
/**
* Two characters, not one.
*
* A single letter matches a large fraction of any library, so the request is slow, the
* results are noise, and it fires on the way to every real query. Multi-word queries are
* untouched only the trimmed length matters.
*/
fun shouldSearch(query: String): Boolean = query.trim().length >= SearchViewModel.MIN_QUERY_LENGTH
/**
* Orders results so the obvious answer is first.
*
* The backend already sorts by its own relevance (Postgres `ts_rank`, or Emby's own
* ordering), and that ordering is *preserved within each tier* this only lifts the
* matches a person would be annoyed to find below the fold. Sorting is stable, so a
* backend that already got it right is not reshuffled.
*/
fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
val term = query.trim().lowercase()
if (term.isEmpty()) return items
return items.sortedBy { item -> matchTier(term, item) }
}
private fun matchTier(term: String, item: BaseItem): Int {
val name = item.name.trim().lowercase()
val series = item.seriesName.orEmpty().trim().lowercase()
return when {
name == term -> 0
series == term -> 1
name.startsWith(term) -> 2
series.startsWith(term) -> 3
name.containsWordStartingWith(term) -> 4
name.contains(term) -> 5
series.contains(term) -> 6
// Everything else the backend returned: genre, year or overview matches. Kept,
// because "no exact match but here is the related thing" beats an empty pane.
else -> 7
}
}
/** "star" should rank higher in "Lone Star" than in "Costar". */
private fun String.containsWordStartingWith(term: String): Boolean =
split(' ', '-', ':', '.', '\'').any { it.startsWith(term) }
@@ -0,0 +1,358 @@
package com.ponzischeme89.memby.ui.seasonal
import android.provider.Settings as AndroidSettings
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/**
* Snow, bats and blossom, drifting over the launcher for the few days a year a seasonal
* theme is on.
*
* A palette on its own is a thin idea of Christmas the colours change and nothing about
* the television says why. This is the half that does. It is also, by a distance, the most
* expensive thing in the app: the only animation that runs continuously while somebody is
* simply browsing, on boxes that struggle with the launcher as it is. Everything below is
* shaped by that.
*
* **Nothing here recomposes.** The repo rule (see CLAUDE.md, "Animations must not
* recompose") is the whole design: one `Canvas`, one animated `State<Float>` that is never
* read in a composable body, and every particle's position derived arithmetically from it
* inside the draw lambda. So a full field of decorations costs *zero* recompositions and one
* draw pass a scope that already redraws whenever the launcher does.
*
* **The field is a pure function of one number**, which is what makes it both cheap and
* testable: [drawSeasonalField] takes a progress in `0..1` and draws exactly one frame, so a
* screenshot test can render the moment without an animation clock. There is no per-particle
* state, no list to allocate and nothing to keep in step a particle is `index` and
* `progress` and nothing else.
*
* **Everything wraps seamlessly.** Each particle's cycle counts are whole numbers, so at the
* instant the driving value rolls 1 0 every position, sway and rotation is exactly where
* it was. Without that the entire field would visibly jump once a minute, which is worse
* than no animation at all.
*/
/** What the gateway asks for. An unknown slug draws nothing; see [drawSeasonalField]. */
object Decorations {
const val SNOW = "snow"
const val BATS = "bats"
const val BLOSSOM = "blossom"
}
/**
* How many of the things are on screen at once.
*
* Deliberately low. This is decoration behind a launcher somebody is trying to read, and the
* failure mode is not "too few" it is a set that drops frames while scrolling a row, which
* nobody would connect to Christmas. Twenty-two is enough to read as weather at 1080p and
* cheap enough to draw in a handful of paths.
*/
private const val ParticleCount = 26
/** One full cycle of the field, in milliseconds. Long, because this is meant to be barely noticed. */
private const val CycleMillis = 48_000
/**
* The decoration layer for the launcher.
*
* Never focusable, never clickable, and drawn with no scrim of its own it sits over
* artwork somebody is choosing from, so anything that dimmed the page to make the snow read
* better would have the priority exactly backwards.
*
* Call it with the gateway's slug. Empty, unknown, or a platform with animations turned off
* all produce nothing at all not an empty Canvas, but no node, so there is nothing in the
* tree for the other 51 weeks of the year.
*/
@Composable
fun SeasonalDecorations(decoration: String, modifier: Modifier = Modifier) {
if (!hasField(decoration)) return
// Somebody who has turned animations off at the platform level has said something about
// every animation on the device, and this is the least important one on it. Honoured
// here rather than exposed as a Memby setting, because a seasonal theme is deliberately
// not the viewer's to decline — but an accessibility choice is not a preference, and
// "no animations" has to mean no animations.
val context = LocalContext.current
val animationsOn = remember(context) {
AndroidSettings.Global.getFloat(
context.contentResolver,
AndroidSettings.Global.ANIMATOR_DURATION_SCALE,
1f,
) > 0f
}
if (!animationsOn) return
val progress = rememberInfiniteTransition(label = "seasonal").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(CycleMillis, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "drift",
)
// The accent and the body colour are read here, in the composable, on purpose: they are
// palette state, and reading them in composition means a theme change recomposes this
// one node rather than being missed. The *animated* value is the one that must not be
// read here, and is not — `progress` is passed down as State and unwrapped in the draw
// lambda below.
val accent = MembyAccent
val body = MembyOnSurface
Canvas(modifier = modifier.fillMaxSize().testTag(DecorationTestTag)) {
drawSeasonalField(decoration, progress.value, accent, body)
}
}
/** So a screenshot test can find the layer without reaching into the drawing. */
const val DecorationTestTag = "seasonal-decorations"
/** Whether [decoration] is one this build knows how to draw. */
fun hasField(decoration: String): Boolean = when (decoration) {
Decorations.SNOW, Decorations.BATS, Decorations.BLOSSOM -> true
else -> false
}
/**
* One frame of the field, at [progress] through a cycle.
*
* Pure with respect to everything except the canvas: the same progress always draws the same
* picture, which is what the screenshot test relies on and what lets the animation be
* verified by looking at three still images rather than by watching a television.
*/
fun DrawScope.drawSeasonalField(
decoration: String,
progress: Float,
accent: Color,
body: Color,
) {
if (!hasField(decoration)) return
for (index in 0 until ParticleCount) {
drawParticle(decoration, index, progress, accent, body)
}
}
private fun DrawScope.drawParticle(
decoration: String,
index: Int,
progress: Float,
accent: Color,
body: Color,
) {
// Everything about a particle comes out of its index through these two hashes. A stored
// array of particles would be the obvious shape and is the wrong one here: it is state to
// allocate, keep and re-seed on every configuration change, in exchange for randomness
// nobody can tell from this.
val a = noise(index * 2)
val b = noise(index * 2 + 1)
// **Stratified, not random.** The hash alone put visible bands and a bare patch through
// the middle of the screen — twenty-two samples is far too few for a hash to look evenly
// spread, and the eye finds a clump in a snowfield immediately. So each particle owns a
// slice of the axis and the hash only jitters it within that slice, which is what makes
// this read as weather rather than as a handful of dots.
val lane = (index + 0.5f) / ParticleCount
val jitter = (a - 0.5f) / ParticleCount
// Whole numbers, so the field is exactly where it started when progress rolls over.
val speed = 2 + index % 4
val swayCycles = 1 + index % 3
val scale = 0.82f + b * 0.55f
// Faint on purpose, and this is the number that was tuned by looking rather than
// reasoned about. The layer sits *over* the launcher — it has to, since the surface
// beneath it is opaque — so every so often a flake lands on the Play button. At this
// alpha that reads as snow passing in front of the screen; a few points higher and it
// reads as something wrong with the rendering. See `decoration-over-content.png`, which
// exists for exactly this judgement.
val alpha = 0.17f + a * 0.18f
val sway = sin((progress * swayCycles + a) * 2f * PI.toFloat())
when (decoration) {
Decorations.SNOW -> {
val x = (lane + jitter + sway * 0.035f) * size.width
val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
place(x, y, (progress * (1 + index % 2) + a) * 360f) {
drawSnowflake(14f * scale, body.copy(alpha = alpha))
}
}
Decorations.BLOSSOM -> {
val x = (lane + jitter + sway * 0.06f) * size.width
val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
// Blossom tumbles rather than spinning flat, so it turns faster than snow —
// the rotation is what sells it as falling rather than sliding down the screen.
place(x, y, (progress * (2 + index % 3) + b) * 360f) {
// Barely blended toward the body colour. A single petal was drawn at half
// the way and came out a grey seed: these palettes are pastels already, and
// mixing a pastel with a near-white leaves nothing of the colour behind.
drawBlossom(13f * scale, accent.copy(alpha = alpha + 0.08f), body)
}
}
Decorations.BATS -> {
// Bats fly across rather than fall, and alternate direction so the screen does
// not read as everything leaving one side of the room.
val leftward = index % 2 == 0
val travel = wrap(lane + progress * (1 + index % 3))
val x = (if (leftward) travel else 1f - travel) * (size.width + 220f) - 110f
// Banded down the screen rather than hashed, for the same reason the fall is:
// the flock has to look like it is crossing the whole room. The top and bottom
// eighths are left clear so nothing collides with the row titles or the rail.
val band = 0.12f + ((index * 7 % ParticleCount) + b) / ParticleCount * 0.76f
val y = (band + sin((progress * (2 + index % 2) + a) * 2f * PI.toFloat()) * 0.04f) *
size.height
place(x, y, 0f) {
// The wings beat by squeezing the shape horizontally — six flaps a cycle,
// whole-numbered like everything else. A cheaper animation than redrawing a
// wing path, and at this size it is the only cue that reads as alive.
val flap = 0.45f + abs(sin((progress * 6 + a) * 2f * PI.toFloat())) * 0.55f
drawBat(22f * scale, flap, blend(accent, body, 0.12f).copy(alpha = alpha))
}
}
}
}
/** Moves to a point and turns, so each shape can be drawn around its own origin. */
private inline fun DrawScope.place(x: Float, y: Float, degrees: Float, draw: DrawScope.() -> Unit) {
translate(x, y) { rotate(degrees, pivot = Offset.Zero) { draw() } }
}
/** Six spokes and a centre — the shape everybody draws, because at 18px nothing else reads. */
private fun DrawScope.drawSnowflake(radius: Float, color: Color) {
val stroke = (radius * 0.16f).coerceAtLeast(1f)
for (spoke in 0 until 3) {
val angle = spoke * PI.toFloat() / 3f
val dx = cos(angle) * radius
val dy = sin(angle) * radius
drawLine(color, Offset(-dx, -dy), Offset(dx, dy), strokeWidth = stroke)
// The little barbs. Without them a snowflake at this size is a three-line asterisk,
// which reads as a scratch on the panel rather than as snow.
val barb = radius * 0.34f
for (side in listOf(1f, -1f)) {
val tip = Offset(dx * side, dy * side)
val inner = Offset(dx * side * 0.55f, dy * side * 0.55f)
drawLine(
color,
inner,
Offset(
inner.x + cos(angle + side * 1.05f) * barb,
inner.y + sin(angle + side * 1.05f) * barb,
),
strokeWidth = stroke * 0.8f,
)
drawLine(color, inner, tip, strokeWidth = stroke)
}
}
drawCircle(color, radius * 0.18f, Offset.Zero)
}
/**
* A five-petal blossom: five overlapping discs and a centre.
*
* It began as a single petal, which at this size rendered as a grey seed recognisable as
* *something falling* and as nothing else. A whole flower is barely more expensive (five
* circles against a two-curve path) and is read instantly, which for the one decoration
* whose season lasts four days is the difference between the feature landing and not.
*/
private fun DrawScope.drawBlossom(radius: Float, color: Color, body: Color) {
val petal = radius * 0.44f
val reach = radius * 0.56f
for (index in 0 until 5) {
val angle = index * 2f * PI.toFloat() / 5f
drawCircle(color, petal, Offset(cos(angle) * reach, sin(angle) * reach))
}
// The centre is warmer and a touch stronger, which is what stops five discs reading as
// a cluster of bubbles.
drawCircle(
blend(color, body, 0.45f).copy(alpha = (color.alpha * 1.5f).coerceAtMost(1f)),
radius * 0.3f,
Offset.Zero,
)
}
/**
* A bat silhouette, [flap] squeezing the wings between folded and spread.
*
* Drawn as a body plus two scalloped wings rather than as one path, because the scallop is
* the only thing separating a bat from a bird at fourteen pixels.
*/
private fun DrawScope.drawBat(radius: Float, flap: Float, color: Color) {
val span = radius * flap
val path = Path().apply {
moveTo(0f, -radius * 0.18f)
// Right wing: out along the top, back in through two scallops.
cubicTo(span * 0.5f, -radius * 0.75f, span * 0.85f, -radius * 0.5f, span, -radius * 0.1f)
lineTo(span * 0.72f, radius * 0.22f)
lineTo(span * 0.6f, radius * 0.02f)
lineTo(span * 0.34f, radius * 0.3f)
lineTo(span * 0.22f, radius * 0.08f)
lineTo(0f, radius * 0.34f)
// Left wing: the mirror of it.
lineTo(-span * 0.22f, radius * 0.08f)
lineTo(-span * 0.34f, radius * 0.3f)
lineTo(-span * 0.6f, radius * 0.02f)
lineTo(-span * 0.72f, radius * 0.22f)
lineTo(-span, -radius * 0.1f)
cubicTo(-span * 0.85f, -radius * 0.5f, -span * 0.5f, -radius * 0.75f, 0f, -radius * 0.18f)
close()
}
drawPath(path, color)
drawCircle(color, radius * 0.2f, Offset(0f, -radius * 0.12f))
// Two ears. Tiny, and the reason the silhouette is legible at all.
drawPath(
Path().apply {
moveTo(-radius * 0.2f, -radius * 0.22f)
lineTo(-radius * 0.1f, -radius * 0.5f)
lineTo(-radius * 0.02f, -radius * 0.24f)
close()
moveTo(radius * 0.2f, -radius * 0.22f)
lineTo(radius * 0.1f, -radius * 0.5f)
lineTo(radius * 0.02f, -radius * 0.24f)
close()
},
color,
)
}
/**
* A deterministic value in `0..1` for a particle's index.
*
* A hash rather than `Random`, so the field is identical on every television, on every
* launch, and in a screenshot which is what makes a still image of it worth looking at.
*/
private fun noise(seed: Int): Float {
var value = seed * 374_761_393 + 668_265_263
value = (value xor (value shr 13)) * 1_274_126_177
return (abs(value xor (value shr 16)) % 10_000) / 10_000f
}
/** The fractional part, so a particle leaving the bottom re-enters at the top. */
private fun wrap(value: Float): Float = value - kotlin.math.floor(value)
private fun blend(from: Color, to: Color, amount: Float): Color = Color(
red = from.red + (to.red - from.red) * amount,
green = from.green + (to.green - from.green) * amount,
blue = from.blue + (to.blue - from.blue) * amount,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.settings
/** One release as the About page shows it. */
internal data class ReleaseNote(
val version: String,
val date: String,
val changes: List<String>,
)
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
/**
* Parses CHANGELOG.md into the release list. Pure so it can be unit-tested, and
* deliberately forgiving: anything that is not a `## <version>` heading or a `- ` bullet
* under one is prose (the file's own format notes) and is skipped rather than rendered.
*
* Order is the file's own newest first by convention, never re-sorted here, because a
* version string is not reliably comparable once a release carries a suffix.
*/
internal fun parseChangelog(markdown: String): List<ReleaseNote> {
val releases = mutableListOf<ReleaseNote>()
var version: String? = null
var date = ""
var changes = mutableListOf<String>()
fun flush() {
version?.let { releases += ReleaseNote(it, date, changes.toList()) }
}
markdown.lineSequence().forEach { rawLine ->
val line = rawLine.trim()
val heading = HeadingPattern.find(line)
when {
heading != null -> {
flush()
version = heading.groupValues[1]
date = heading.groupValues[2].trim()
changes = mutableListOf()
}
version == null -> Unit
line.startsWith("- ") || line.startsWith("* ") -> changes += line.drop(2).trim()
// A wrapped bullet continues the previous one rather than starting a new
// entry: the file is written to a column width, not to one line per change.
line.isNotEmpty() && changes.isNotEmpty() ->
changes[changes.lastIndex] = "${changes.last()} $line"
}
}
flush()
return releases
}
/** The catalogue Memby ships with, parsed once. */
internal val MembyReleaseHistory: List<ReleaseNote> by lazy {
parseChangelog(com.ponzischeme89.memby.BuildConfig.CHANGELOG_TEXT)
}
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.ui.setup
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.TvTextField
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* The sign-in form, with no dependency on the repository so it can be screenshotted and
* looked at without a server (`OnboardingScreenshotTest`). `SetupScreen` owns the
* authentication and hands the state in.
*
* It sits on flat [MembySurface] like every other full-screen surface in the app. It used to
* be a bordered translucent card floating on a radial gradient, which was the only place in
* Memby that looked like that and it is the *first* thing anyone sees, so it set an
* expectation the rest of the app then contradicted. The panel earned nothing: there is
* nothing behind it to be raised above.
*/
@Composable
fun SignInContent(
username: String,
password: String,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
connecting: Boolean,
error: String?,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
usernameFocus: FocusRequester = remember { FocusRequester() },
passwordFocus: FocusRequester = remember { FocusRequester() },
signInFocus: FocusRequester = remember { FocusRequester() },
backFocus: FocusRequester = remember { FocusRequester() },
) {
val addingViewer = onBack != null
Box(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 72.dp, vertical = 40.dp),
contentAlignment = Alignment.Center,
) {
Column(
// The form is taller than the safe content area on some Android TVs, and a
// scroll container is also what lets Compose lift the focused field above the
// on-screen keyboard instead of clipping it.
modifier = Modifier.width(560.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier.size(52.dp),
)
Spacer(Modifier.height(20.dp))
Text(
if (addingViewer) "Add another viewer" else "Welcome to Memby",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
if (addingViewer) {
"Their account is saved as another profile on this TV."
} else {
"Sign in with your Emby account."
},
color = MembyMutedText,
fontSize = 16.sp,
)
Spacer(Modifier.height(30.dp))
TvTextField(
label = "Username",
value = username,
onValueChange = onUsernameChange,
focusRequester = usernameFocus,
onNext = { passwordFocus.requestFocus() },
)
Spacer(Modifier.height(16.dp))
TvTextField(
label = "Password",
value = password,
onValueChange = onPasswordChange,
isPassword = true,
focusRequester = passwordFocus,
downFocusRequester = signInFocus,
onDone = onSubmit,
)
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = SignInError, fontSize = 15.sp)
}
Spacer(Modifier.height(28.dp))
Row(
modifier = Modifier.fillMaxWidth(),
// Centred under a centred form. End-aligned buttons were the last trace of
// the card this screen used to be, and read as belonging to a dialog.
horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally),
) {
if (onBack != null) {
UpdateButton(
label = "Back",
primary = false,
enabled = !connecting,
onClick = onBack,
modifier = Modifier
.focusRequester(backFocus)
.focusProperties { right = signInFocus },
)
}
UpdateButton(
label = if (connecting) "Signing in…" else "Sign in",
primary = true,
enabled = !connecting,
onClick = onSubmit,
modifier = Modifier
.focusRequester(signInFocus)
.focusProperties { if (onBack != null) left = backFocus },
)
}
}
}
}
/** The one red in the app that means "this went wrong", matched to Settings' own notices. */
private val SignInError = Color(0xFFFF7777)
@@ -0,0 +1,128 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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 colours are now the server's answer, not constants.** A theme is decided by the
* gateway (see `server/internal/api/themes.go`) and handed down as a [MembyPalette]; these
* names are reads of whichever one is in force. Which is why every one of them is a `get()`
* rather than a value: a token captured once at class-init time would be the palette that
* happened to be loaded when the first screen composed, and would never change again.
*
* The shape and punctuation below are *not* themeable and are still constants. A theme
* changes colour and nothing else a palette that could move a corner radius or a
* separator would be able to make a layout wrong from the server, and the whole safety of
* this feature is that the worst a bad theme can do is look bad.
*/
/**
* Every colour a theme sets, and the complete list of them. It matches `themePalette` on
* the gateway field for field: a palette carrying a colour with no slot here would be a
* promise the app cannot keep, and one missing a slot is a theme that half-applies, which
* reads as a rendering fault rather than as a colour scheme.
*
* The defaults are Midnight the palette the app shipped with before themes existed so a
* television with no gateway, no network or no theme yet looks exactly as it always did.
*/
data class MembyPalette(
val surface: Color = Color(0xFF090B0D),
val surfaceRaised: Color = Color(0xFF101418),
val accent: Color = Color(0xFF52B54B),
val onSurface: Color = Color(0xFFE2E5E8),
val mutedText: Color = Color(0xFFD0D6DB),
val quietText: Color = Color(0xFFAEB7BF),
val hairline: Color = Color(0x28FFFFFF),
val ratingsSurface: Color = Color(0xFF20252A),
)
/**
* The palette in force, as snapshot state.
*
* Process-wide rather than a `CompositionLocal`, and that is deliberate. One television has
* one signed-in viewer and therefore one theme, and the surfaces that have to obey it are
* not all inside one composition: the launcher, the detail overlays, the settings sheet, the
* player's Compose islands and the screensaver's `DreamService` are five separate roots. A
* local would have to be provided at each of them and would be silently missing from the
* next one somebody added.
*
* Being snapshot state is what makes the tokens below work without a `@Composable`
* annotation: a read inside composition or a draw scope is recorded, so assigning here
* repaints exactly the scopes that use the colour that changed.
*/
private var activePalette by mutableStateOf(MembyPalette())
/**
* Repaints the app. Called by `ThemeSync` when the gateway's answer changes at sign-in,
* when the viewer picks a scheme, and at the midnight a season begins or ends.
*
* Setting the same palette twice is free: `mutableStateOf` compares with `equals`, and
* [MembyPalette] is a data class, so a poll that resolves to what is already on screen
* invalidates nothing.
*/
fun applyMembyPalette(palette: MembyPalette) {
activePalette = palette
}
/** The palette in force, for the few places that need it whole rather than one colour. */
val membyPalette: MembyPalette get() = activePalette
/** The near-black every full-screen surface is drawn on. */
val MembySurface: Color get() = activePalette.surface
/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */
val MembySurfaceRaised: Color get() = activePalette.surfaceRaised
/** The single accent. Emby's green on the default theme; a theme may make it anything. */
val MembyAccent: Color get() = activePalette.accent
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
val MembyOnSurface: Color get() = activePalette.onSurface
/**
* 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 get() = activePalette.mutedText
val MembyQuietText: Color get() = activePalette.quietText
/** Hairline rules and unfocused borders. */
val MembyHairline: Color get() = activePalette.hairline
/** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface: Color get() = activePalette.ratingsSurface
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
// than inventing a fourth. Not themeable; see the note at the top of this file.
/** 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 = " · "

Some files were not shown because too many files have changed in this diff Show More