Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598a4f5c75 | ||
|
|
52e167ea59 | ||
|
|
0dffd59440 | ||
|
|
574826dc03 | ||
|
|
add8aa831c | ||
|
|
0c623bbcc0 | ||
|
|
fdd9e6cab2 | ||
|
|
b1128bcce2 | ||
|
|
80c304d86b | ||
|
|
4a4df7a73c | ||
|
|
2675e6d82b | ||
|
|
666da9c5d3 | ||
|
|
b6b2a9c25a | ||
|
|
1ed180c739 | ||
|
|
f12042ad24 | ||
|
|
a265636139 | ||
|
|
70914400b4 | ||
|
|
8d6cf2f5a1 |
+93
-9
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
# Design fixes — home screen and detail pages
|
||||||
|
|
||||||
|
Audit of 2026-08-01. Findings only; no product code was changed. The three items under
|
||||||
|
"Confirmed layout bugs" were reproduced by rendering the real composables at TV 1080p
|
||||||
|
(`w960dp-h540dp-television-xhdpi`) through the existing Roborazzi harness — everything
|
||||||
|
else is read from the source.
|
||||||
|
|
||||||
|
**All of it is implemented.** The findings below are left as written — they are the
|
||||||
|
diagnosis, and each one says why the fix is shaped the way it is. What was done:
|
||||||
|
|
||||||
|
| # | Fixed in |
|
||||||
|
|---|----------|
|
||||||
|
| 1 | `HomeMovieHero.kt` — column padding 28→22dp, and a wrapped title stands the synopsis down (`onTextLayout` line count) so Play is never what gets cut. Captured as `df_home-movie-hero-long-title.png`. |
|
||||||
|
| 2 | `DetailPageComponents.detailPaneHeight()` — the slot is derived from the viewport (250–420dp) instead of a hard 250dp. Every technical spec now renders; `df_detail-pane-cast-details.png`. Studio, which both columns claimed, is dropped from the technical column. |
|
||||||
|
| 3 | `DetailFoldPeek` (34dp) holds the tab strip off the bottom edge. |
|
||||||
|
| 4 | The hero's private `heroFacts`/runtime formatter is gone; it calls `detail/DetailFacts.kt`. `HomeComponents.formatTvRuntime` too — one formatter left in the app. |
|
||||||
|
| 5 | `MembyScore` token, and every rating goes through `ratingLabel` (`Locale.US`). The home metadata panel renders the score as its own run of text so it can carry the same gold. |
|
||||||
|
| 6 | "No favourite shows yet" / "Mark a series as a favourite…". |
|
||||||
|
| 7 | `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. |
|
||||||
|
| 8 | `ui/theme/DesignTokens.kt`; `HomeComponents` and `DetailPageComponents` colours are aliases of it, and `Theme.kt` uses the same near-blacks. |
|
||||||
|
| 9 | Same — the detail page picked up the raised TV neutrals. |
|
||||||
|
| 10 | Three radii: `MembyChipCorner` 8, `MembyCardCorner` 10, `MembyPanelCorner` 14. |
|
||||||
|
| 11 | `ui/MembyButtons.kt` — `MembyPlayButton`, `MembyPlayChip`, `MembyChoiceChip`. The hero chip, the detail Play button, the metadata panel's "▶ Resume" and the For You time budget all use them. |
|
||||||
|
| 12 | `DetailHero` honours `Settings.showTitleLogo` (new `EmbyRepository.showTitleLogo`) and shares `useTextTitleForLogo` with the screensaver (`ui/TitleLogo.kt`). |
|
||||||
|
| 13 | One `UHD_MIN_WIDTH` (3800) for the badge and the `(4K)` suffix. Unit-tested. |
|
||||||
|
| 14 | `mediaBadges` reads `dynamicRangeLabel`, so HDR10+ stays HDR10+. Unit-tested. |
|
||||||
|
| 15 | One `FocusRequester` per pane in both overlays; none is attached to two live nodes. |
|
||||||
|
| 16 | A series passes `mediaBadges(item)`, and `DetailFactRow` takes 4 badges so the airing badge is not squeezed out. |
|
||||||
|
| 17 | `HomeRowHeaderIcon` / `HomeRowHeaderIconGap` / `HomeRowHeaderSpacing`, used by `MediaRow`, `MyShowsStrip` and `RecentSearchesRow` — which also gained the vertical padding its focus-scaled chips needed. |
|
||||||
|
| 18 | `HomeHeroPick` carries the row a title was drawn from; the caption is no longer the card's slot. Unit-tested. |
|
||||||
|
| 19 | Deleted (≈260 lines: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData`, `HomeRowSkeleton` and the two runtime formatters only they used). |
|
||||||
|
| 20 | The peek under the strip plus a chevron at its end. |
|
||||||
|
| Docs | `CLAUDE.md`'s detail-page section rewritten to describe this code, with the token, button and header conventions above it. |
|
||||||
|
|
||||||
|
Screenshots of the result are `app/build/screenshots/df_*.png`
|
||||||
|
(`.\gradlew.bat :app:testDebugUnitTest --tests "*ScreenshotTest"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confirmed layout bugs
|
||||||
|
|
||||||
|
These clip real content on a real TV. Fix these first.
|
||||||
|
|
||||||
|
### 1. The featured home hero drops its Play button when the title wraps to two lines
|
||||||
|
|
||||||
|
`ui/HomeMovieHero.kt:171-236`, `ui/MainActivity.kt:137` (`homeHeaderHeight`)
|
||||||
|
|
||||||
|
The card's content column measures ~229dp with a one-line title and ~261dp with two.
|
||||||
|
`homeHeaderHeight(540dp, showHero = true)` yields 248dp, minus the hero row's 16dp top and
|
||||||
|
10dp bottom padding, so the card gets **222dp** — and `FocusScaleContainer` clips it to a
|
||||||
|
14dp rounded rect. Rendered with a two-line title, the kicker, title, fact line and
|
||||||
|
synopsis draw and the green Play chip is **gone entirely**. A one-line title is already
|
||||||
|
7dp over budget; it only survives because the part cut off is the chip's shadow.
|
||||||
|
|
||||||
|
Fix direction: the column is `align(Alignment.CenterStart)` inside a fixed-height box, so
|
||||||
|
overflow is split top and bottom and the button is always the first thing lost. Either
|
||||||
|
give the hero a height derived from its content, cap the title at one line, or drop the
|
||||||
|
synopsis when the title wraps.
|
||||||
|
|
||||||
|
### 2. The Cast & Details tab silently discards every technical spec
|
||||||
|
|
||||||
|
`ui/DetailPageComponents.kt:258-278` (the 250dp pane), `:563-580` (`DetailFocusablePane`),
|
||||||
|
`:613-643` (`DetailCastAndDetailsPane`)
|
||||||
|
|
||||||
|
The tab content slot is a hard `.height(250.dp)` and `DetailFocusablePane` applies
|
||||||
|
`.clip(RoundedCornerShape(10.dp))`. `releaseAndTechnical` builds Released / Certificate /
|
||||||
|
Runtime and then `addAll(specs)` — Video, Codec, Audio, Subtitles, Studio. Rendered at the
|
||||||
|
real slot geometry, only the first three rows survive; the entire output of
|
||||||
|
`technicalSpecs()` is clipped below the fold of a pane that cannot scroll. That is the
|
||||||
|
whole reason the tab exists.
|
||||||
|
|
||||||
|
`DetailOverviewPane` shares the ceiling: a five-line synopsis plus the series "Up next"
|
||||||
|
supporting line pushes its credit rows past the same boundary.
|
||||||
|
|
||||||
|
Fix direction: the pane needs a height budget that accounts for its worst case, or the
|
||||||
|
two-column meta block needs to page/scroll. Note the pane deliberately does not scroll
|
||||||
|
(one screen per tab), so the honest fix is probably fewer rows per column, not a scroller.
|
||||||
|
|
||||||
|
### 3. The detail tab strip sits flush against the bottom screen edge
|
||||||
|
|
||||||
|
`ui/DetailPageComponents.kt:209` (`heroHeight = maxHeight - DetailTabHeight`), `:494-550`
|
||||||
|
|
||||||
|
The selection underline is cut in half at y=1080 in both `detail-movie-more-like-this.png`
|
||||||
|
and `detail-series-cast-details.png`. On a TV with overscan the underline and part of the
|
||||||
|
labels are off-screen. This is the only element in the app with zero safe-area inset —
|
||||||
|
gutters are 36-58dp and the home clock keeps 18dp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copy and formatting
|
||||||
|
|
||||||
|
### 4. The home hero formats runtime differently from everywhere else
|
||||||
|
|
||||||
|
`ui/HomeMovieHero.kt:351` is a private `heroFacts` shadowing `ui/detail/DetailFacts.kt:62`.
|
||||||
|
Different field order (year · certificate · runtime vs year · runtime · certificate) and
|
||||||
|
`"${it}m"` instead of `formatRuntime`. The checked-in `home-movie-hero.png` shows
|
||||||
|
**"2026 • M • 124m"** in the hero and **"2026 • 2h 4m"** on the card directly beneath it.
|
||||||
|
|
||||||
|
There are three runtime formatters in the app: `DetailFacts.formatRuntime`,
|
||||||
|
`HomeComponents.formatTvRuntime` (private, identical) and this one.
|
||||||
|
|
||||||
|
### 5. The community score changes colour and locale by screen
|
||||||
|
|
||||||
|
Gold `0xFFF5C518` on detail (`DetailPageComponents.kt:484`), grey `MutedText` on home
|
||||||
|
(`HomeComponents.kt:1025`). `HomeMovieHero.kt:355` uses `"★ %.1f".format(it)` with the
|
||||||
|
default locale while every other rating goes through `Locale.US` — a comma decimal in a
|
||||||
|
non-US locale.
|
||||||
|
|
||||||
|
### 6. American spelling in two user-facing strings
|
||||||
|
|
||||||
|
`ui/HomeComponents.kt:1549` "No **favorite** shows yet" and `:1555` "Mark a series as a
|
||||||
|
**favorite** and it'll be waiting here." The rail says Favourites, the quick menu says
|
||||||
|
"Add to favourites", the detail hero action says "Add to Favourites".
|
||||||
|
|
||||||
|
### 7. Four separator styles for the same kind of fact list
|
||||||
|
|
||||||
|
`" • "` (home metadata), `" • "` (detail fact row, schedule metadata, home hero),
|
||||||
|
`" · "` (home genres), `", "` (credit rows).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design-token drift
|
||||||
|
|
||||||
|
### 8. Four near-blacks
|
||||||
|
|
||||||
|
Theme `background 0xFF0B0E11` and `surface 0xFF101418` (`ui/theme/Theme.kt`, effectively
|
||||||
|
unused), home `0xFF090B0D`, detail `0xFF080A0C`. The accent green is duplicated four ways:
|
||||||
|
`EmbyGreen`, `DetailAccent`, and the hero's `0xFF69C762` / `0xFF7BD574`.
|
||||||
|
|
||||||
|
### 9. Secondary-text contrast diverged between the two screens
|
||||||
|
|
||||||
|
`ui/HomeComponents.kt:129-132` carries a comment about raising the neutrals for TV distance
|
||||||
|
(`MutedText 0xFFD0D6DB`, `QuietText 0xFFAEB7BF`). The detail page still uses the pre-fix
|
||||||
|
values (`DetailMutedText 0xFFB6BDC3`, `DetailQuietText 0xFF8C959D`). The two sit side by
|
||||||
|
side the moment a detail page is opened from a row.
|
||||||
|
|
||||||
|
### 10. Corner radii are ad hoc
|
||||||
|
|
||||||
|
9dp home cards, 8dp related posters, 8/10dp cast cards, 14dp featured hero, 11dp mini hero,
|
||||||
|
12dp overlays, 7dp chips, 999dp search chips.
|
||||||
|
|
||||||
|
### 11. Three button languages
|
||||||
|
|
||||||
|
The hand-rolled `DetailPlayButton` (23/12dp padding, 16sp), the hero's hand-rolled play chip
|
||||||
|
(12/7dp, 13sp — same look, different metrics), and raw `androidx.tv.material3.Button` with
|
||||||
|
glyph text in `MediaMetadataPanel` ("▶ Resume") and `ForYouTimeBudget` ("✓ 30 min"), which
|
||||||
|
picks up theme colours nothing else in the app uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logic and behaviour
|
||||||
|
|
||||||
|
### 12. `showTitleLogo` is ignored by the detail pages
|
||||||
|
|
||||||
|
The Settings copy promises "shows each title's logo artwork from Emby instead of plain
|
||||||
|
text", but only `ui/screensaver/ScreensaverContent.kt:773` honours it;
|
||||||
|
`ui/DetailPageComponents.kt:322` always fetches the logo. The screensaver also has
|
||||||
|
`useTextTitleForLogo`, a fallback for logos too dark to read — the detail hero has no
|
||||||
|
equivalent, so a dark logo is invisible on the near-black scrim.
|
||||||
|
|
||||||
|
### 13. Two different 4K thresholds
|
||||||
|
|
||||||
|
The badge fires at video width ≥ 3800 (`HomeComponents.kt:1184`); the `(4K)` suffix at
|
||||||
|
≥ 3400 (`DetailFacts.kt:190`). A 3600-wide file is 4K in the spec row and not on the badge.
|
||||||
|
|
||||||
|
### 14. HDR10+ is named in `dynamicRangeLabel` but collapses to plain "HDR" in `mediaBadges`
|
||||||
|
|
||||||
|
### 15. One `FocusRequester` attached to two live nodes
|
||||||
|
|
||||||
|
`informationPane` is attached by the Overview pane, the Cast & Details pane and the Episodes
|
||||||
|
empty states (`ui/MediaDetailsOverlay.kt:202-216`, `ui/SeriesDetailsOverlay.kt:291-329`).
|
||||||
|
`AnimatedContent`'s 80ms fade-out keeps the outgoing pane composed, so a Down press landing
|
||||||
|
in that window can request focus on a pane that is disappearing.
|
||||||
|
|
||||||
|
### 16. A series can never show format badges
|
||||||
|
|
||||||
|
`ui/SeriesDetailsOverlay.kt:248` passes `badges = emptyList()` where movies pass real ones.
|
||||||
|
Related: `DetailFactRow` does `badges.take(3)`, so on a 4K/HDR/HEVC movie the airing badge
|
||||||
|
appended in `ui/MediaDetailsOverlay.kt:147` is silently dropped.
|
||||||
|
|
||||||
|
### 17. Home row headers do not align
|
||||||
|
|
||||||
|
`MediaRow` and `MyShowsStrip` lead with a 28dp icon chip plus 10dp; `RecentSearchesRow`
|
||||||
|
(`ui/MainActivity.kt:2084`) has no chip, so its title starts 38dp further left. It also uses
|
||||||
|
9dp header spacing against everyone else's 6dp and gives its `LazyRow` no vertical padding,
|
||||||
|
so focus-scaled chips have no room to grow.
|
||||||
|
|
||||||
|
### 18. The mini hero labels are positional fiction
|
||||||
|
|
||||||
|
`ui/HomeMovieHero.kt:124`: `listOf("POPULAR", "NEW RELEASE", "TRENDING")[index]`. But
|
||||||
|
`selectHomeHeroMovies` interleaves new releases and popular picks and then falls back to
|
||||||
|
every movie in the response. In the current screenshot a 2025 title is labelled NEW RELEASE
|
||||||
|
and a 2026 one POPULAR.
|
||||||
|
|
||||||
|
### 19. A dead second home implementation
|
||||||
|
|
||||||
|
`ui/MainActivity.kt:2752-2900`: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData` and
|
||||||
|
`HomeRowSkeleton` are unreferenced (only the gateway *model* named `HomeRow` is in use).
|
||||||
|
They carry a competing 48dp gutter and SemiBold header style — a live-looking template for
|
||||||
|
the wrong conventions.
|
||||||
|
|
||||||
|
### 20. Tab content sits entirely below the fold with no affordance
|
||||||
|
|
||||||
|
By design per the code comments, but nothing on screen tells the viewer that Down reveals
|
||||||
|
anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`CLAUDE.md`'s detail-page section no longer describes this code. It documents a poster-left
|
||||||
|
layout with Play hanging off the poster's bottom-right corner, "nothing scrolls vertically",
|
||||||
|
a tab list of "Overview, Episodes, Cast, Details", and a `DetailReasonStrip` of several
|
||||||
|
short phrases. The code is a full-bleed scrolling hero with Overview / Episodes / More Like
|
||||||
|
This / Cast & Details and a single reason line (`ui/DetailPageComponents.kt:375-385`).
|
||||||
|
Worth correcting before it misleads the next change.
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
|
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+43
-5
@@ -1,8 +1,46 @@
|
|||||||
# kotlinx.serialization keeps @Serializable metadata via generated serializers.
|
# R8 rules for release builds (isMinifyEnabled = true).
|
||||||
-keepattributes *Annotation*, InnerClasses
|
#
|
||||||
|
# Most of what this app depends on ships its own consumer rules inside the AAR/JAR —
|
||||||
|
# Retrofit, OkHttp, Coil, Media3 and kotlinx.serialization all do. What follows is the
|
||||||
|
# part that is ours, plus a little insurance around reflection-shaped code.
|
||||||
|
|
||||||
|
# --- kotlinx.serialization -----------------------------------------------------------
|
||||||
|
# Every wire model is @Serializable and the gateway contract is pinned by field name, so
|
||||||
|
# a stripped or renamed serializer is a silent parse failure at runtime rather than a
|
||||||
|
# build error. The same models are also persisted to DataStore as JSON, which means an
|
||||||
|
# existing install decodes a blob written by the previous build.
|
||||||
|
#
|
||||||
|
# These rules match on the *annotation*, not on a package list. The previous version
|
||||||
|
# enumerated com.mattcohen.embyscreensaver.data.model — the package the app used through
|
||||||
|
# v0.1.53 — and so had quietly matched nothing at all. Serializable types today live in
|
||||||
|
# data.model, data and update; keying off @Serializable means moving one never breaks it.
|
||||||
|
-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault
|
||||||
-dontnote kotlinx.serialization.**
|
-dontnote kotlinx.serialization.**
|
||||||
-keepclassmembers class **$$serializer { *; }
|
|
||||||
-keepclasseswithmembers class com.mattcohen.embyscreensaver.data.model.** {
|
-keep @kotlinx.serialization.Serializable class com.ponzischeme89.memby.** { *; }
|
||||||
|
-keepclassmembers class com.ponzischeme89.memby.** {
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class com.ponzischeme89.memby.** {
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
-keep,includedescriptorclasses class com.mattcohen.embyscreensaver.data.model.**$$serializer { *; }
|
-keepclassmembers class **$$serializer { *; }
|
||||||
|
-keep,includedescriptorclasses class com.ponzischeme89.memby.**$$serializer { *; }
|
||||||
|
|
||||||
|
# --- Retrofit ------------------------------------------------------------------------
|
||||||
|
# The API interfaces are consumed reflectively; their generic return types must survive
|
||||||
|
# or Retrofit cannot work out what to deserialize into.
|
||||||
|
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.EmbyApi
|
||||||
|
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi
|
||||||
|
-keepattributes Exceptions
|
||||||
|
|
||||||
|
# --- Media3 / coroutines -------------------------------------------------------------
|
||||||
|
-dontwarn androidx.media3.**
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# --- Crash readability ---------------------------------------------------------------
|
||||||
|
# Releases are self-hosted with no crash reporter, so a stack trace read off a TV over
|
||||||
|
# adb is the only diagnostic there is. Line numbers cost a little dex size and are worth
|
||||||
|
# it; SourceFile is renamed so it does not leak original paths.
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
-renamesourcefileattribute SourceFile
|
||||||
|
|||||||
@@ -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 90–98%
|
||||||
|
* 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()
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* Playback capability probing adapted from Wholphin's
|
||||||
|
* MediaCodecCapabilitiesTest.kt, which is itself derived from Jellyfin Android TV.
|
||||||
|
*
|
||||||
|
* Wholphin: https://github.com/damontecres/Wholphin
|
||||||
|
* Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
|
||||||
|
*
|
||||||
|
* Modifications Copyright (C) 2026 Memby contributors
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.ponzischeme89.memby.data.playback
|
||||||
|
|
||||||
|
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
data class VideoDecoderCapabilities(
|
||||||
|
val supported: Boolean = false,
|
||||||
|
val profiles: Set<String> = emptySet(),
|
||||||
|
val mainLevel: Int = 0,
|
||||||
|
val tenBitLevel: Int = 0,
|
||||||
|
val maxWidth: Int = 0,
|
||||||
|
val maxHeight: Int = 0,
|
||||||
|
val hdr10: Boolean = false,
|
||||||
|
val hdr10Plus: Boolean = false,
|
||||||
|
val dolbyVision: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DevicePlaybackCapabilities(
|
||||||
|
val h264: VideoDecoderCapabilities = VideoDecoderCapabilities(supported = true),
|
||||||
|
val hevc: VideoDecoderCapabilities = VideoDecoderCapabilities(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expensive platform query is performed once, off the UI thread on first network or
|
||||||
|
* direct-play negotiation. A single immutable result then describes this installed build
|
||||||
|
* to both the Memby gateway and Emby itself.
|
||||||
|
*/
|
||||||
|
val devicePlaybackCapabilities: DevicePlaybackCapabilities by lazy {
|
||||||
|
runCatching { AndroidVideoCapabilityProbe().probe() }
|
||||||
|
// H.264 is Android's baseline playback format and remains the safe fallback when
|
||||||
|
// a vendor codec exposes incomplete or broken MediaCodec metadata.
|
||||||
|
.getOrElse { DevicePlaybackCapabilities() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DevicePlaybackCapabilities.gatewayCapabilityTokens(): List<String> = buildList {
|
||||||
|
if (h264.supported) add("video_h264_decode")
|
||||||
|
h264.profiles.sorted().forEach { add("video_h264_profile_$it") }
|
||||||
|
if (h264.mainLevel > 0) add("video_h264_level_${h264.mainLevel}")
|
||||||
|
if (h264.tenBitLevel > 0) add("video_h264_high10_level_${h264.tenBitLevel}")
|
||||||
|
addResolution("video_h264", h264)
|
||||||
|
|
||||||
|
if (hevc.supported) add("video_hevc_decode")
|
||||||
|
hevc.profiles.sorted().forEach { add("video_hevc_profile_$it") }
|
||||||
|
if (hevc.mainLevel > 0) add("video_hevc_main_level_${hevc.mainLevel}")
|
||||||
|
if (hevc.tenBitLevel > 0) add("video_hevc_main10_level_${hevc.tenBitLevel}")
|
||||||
|
addResolution("video_hevc", hevc)
|
||||||
|
if (hevc.hdr10) add("video_hevc_hdr10")
|
||||||
|
if (hevc.hdr10Plus) add("video_hevc_hdr10plus")
|
||||||
|
if (hevc.dolbyVision) add("video_hevc_dolby_vision")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<String>.addResolution(prefix: String, codec: VideoDecoderCapabilities) {
|
||||||
|
if (codec.maxWidth > 0 && codec.maxHeight > 0) {
|
||||||
|
add("${prefix}_max_${codec.maxWidth}x${codec.maxHeight}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AndroidVideoCapabilityProbe {
|
||||||
|
private val codecInfos by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||||
|
|
||||||
|
fun probe(): DevicePlaybackCapabilities = DevicePlaybackCapabilities(
|
||||||
|
h264 = probeH264(),
|
||||||
|
hevc = probeHevc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun probeH264(): VideoDecoderCapabilities {
|
||||||
|
val mime = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
|
val supported = hasCodecForMime(mime)
|
||||||
|
// As in Wholphin, ordinary AVC support covers the backwards-compatible
|
||||||
|
// baseline/main/high family even when vendor metadata lists only its highest
|
||||||
|
// profile. High10 remains opt-in because it is not universally supported.
|
||||||
|
val high10Level = mappedLevel(
|
||||||
|
mime,
|
||||||
|
setOf(CodecProfileLevel.AVCProfileHigh10),
|
||||||
|
AVC_LEVELS,
|
||||||
|
)
|
||||||
|
val profiles = buildSet {
|
||||||
|
if (supported) addAll(listOf("baseline", "constrained_baseline", "main", "high"))
|
||||||
|
if (high10Level > 0) add("high10")
|
||||||
|
}
|
||||||
|
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||||
|
return VideoDecoderCapabilities(
|
||||||
|
supported = supported,
|
||||||
|
profiles = profiles,
|
||||||
|
mainLevel = mappedLevel(
|
||||||
|
mime,
|
||||||
|
setOf(
|
||||||
|
CodecProfileLevel.AVCProfileBaseline,
|
||||||
|
CodecProfileLevel.AVCProfileMain,
|
||||||
|
CodecProfileLevel.AVCProfileHigh,
|
||||||
|
),
|
||||||
|
AVC_LEVELS,
|
||||||
|
),
|
||||||
|
tenBitLevel = high10Level,
|
||||||
|
maxWidth = maxWidth,
|
||||||
|
maxHeight = maxHeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeHevc(): VideoDecoderCapabilities {
|
||||||
|
val mime = MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||||
|
val supported = hasCodecForMime(mime)
|
||||||
|
val mainLevel = mappedLevel(
|
||||||
|
mime,
|
||||||
|
setOf(CodecProfileLevel.HEVCProfileMain),
|
||||||
|
HEVC_LEVELS,
|
||||||
|
)
|
||||||
|
val main10Level = mappedLevel(
|
||||||
|
mime,
|
||||||
|
setOf(CodecProfileLevel.HEVCProfileMain10),
|
||||||
|
HEVC_LEVELS,
|
||||||
|
)
|
||||||
|
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||||
|
return VideoDecoderCapabilities(
|
||||||
|
supported = supported,
|
||||||
|
profiles = buildSet {
|
||||||
|
if (supported) add("main")
|
||||||
|
if (main10Level > 0) add("main10")
|
||||||
|
},
|
||||||
|
mainLevel = mainLevel,
|
||||||
|
tenBitLevel = main10Level,
|
||||||
|
maxWidth = maxWidth,
|
||||||
|
maxHeight = maxHeight,
|
||||||
|
hdr10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||||
|
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10),
|
||||||
|
hdr10Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||||
|
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10Plus),
|
||||||
|
dolbyVision = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||||
|
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCodecForMime(mime: String): Boolean = codecInfos.any { info ->
|
||||||
|
!info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasProfile(mime: String, profile: Int): Boolean =
|
||||||
|
maxPlatformLevel(mime, setOf(profile)) > 0
|
||||||
|
|
||||||
|
private fun mappedLevel(
|
||||||
|
mime: String,
|
||||||
|
profiles: Set<Int>,
|
||||||
|
levels: List<Pair<Int, Int>>,
|
||||||
|
): Int {
|
||||||
|
val platformLevel = maxPlatformLevel(mime, profiles)
|
||||||
|
return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxPlatformLevel(mime: String, profiles: Set<Int>): Int {
|
||||||
|
var maximum = 0
|
||||||
|
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||||
|
runCatching { info.getCapabilitiesForType(mime) }.getOrNull()
|
||||||
|
?.profileLevels
|
||||||
|
?.filter { it.profile in profiles }
|
||||||
|
?.forEach { maximum = maxOf(maximum, it.level) }
|
||||||
|
}
|
||||||
|
return maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxResolution(mime: String): Pair<Int, Int> {
|
||||||
|
var maxWidth = 0
|
||||||
|
var maxHeight = 0
|
||||||
|
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||||
|
val video = runCatching { info.getCapabilitiesForType(mime).videoCapabilities }.getOrNull()
|
||||||
|
?: return@forEach
|
||||||
|
maxWidth = maxOf(maxWidth, video.supportedWidths?.upper ?: 0)
|
||||||
|
maxHeight = maxOf(maxHeight, video.supportedHeights?.upper ?: 0)
|
||||||
|
}
|
||||||
|
return maxWidth to maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// ffprobe/Emby represent AVC levels multiplied by 10 (4.1 -> 41).
|
||||||
|
private val AVC_LEVELS = listOf(
|
||||||
|
CodecProfileLevel.AVCLevel1b to 9,
|
||||||
|
CodecProfileLevel.AVCLevel1 to 10,
|
||||||
|
CodecProfileLevel.AVCLevel11 to 11,
|
||||||
|
CodecProfileLevel.AVCLevel12 to 12,
|
||||||
|
CodecProfileLevel.AVCLevel13 to 13,
|
||||||
|
CodecProfileLevel.AVCLevel2 to 20,
|
||||||
|
CodecProfileLevel.AVCLevel21 to 21,
|
||||||
|
CodecProfileLevel.AVCLevel22 to 22,
|
||||||
|
CodecProfileLevel.AVCLevel3 to 30,
|
||||||
|
CodecProfileLevel.AVCLevel31 to 31,
|
||||||
|
CodecProfileLevel.AVCLevel32 to 32,
|
||||||
|
CodecProfileLevel.AVCLevel4 to 40,
|
||||||
|
CodecProfileLevel.AVCLevel41 to 41,
|
||||||
|
CodecProfileLevel.AVCLevel42 to 42,
|
||||||
|
CodecProfileLevel.AVCLevel5 to 50,
|
||||||
|
CodecProfileLevel.AVCLevel51 to 51,
|
||||||
|
CodecProfileLevel.AVCLevel52 to 52,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ffprobe/Emby represent HEVC levels multiplied by 30 (4.1 -> 123).
|
||||||
|
private val HEVC_LEVELS = listOf(
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel1 to 30,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel2 to 60,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel21 to 63,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel3 to 90,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel31 to 93,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel4 to 120,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel41 to 123,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel5 to 150,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel51 to 153,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel52 to 156,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel6 to 180,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel61 to 183,
|
||||||
|
CodecProfileLevel.HEVCMainTierLevel62 to 186,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. You’ve earned the good seat.",
|
||||||
|
),
|
||||||
|
WelcomeQuoteStyle.HOMICIDAL to listOf(
|
||||||
|
"Welcome back. I kept your spot. Nobody argued twice.",
|
||||||
|
"Pick something cheerful. I’ve already hidden the evidence.",
|
||||||
|
"The remote knows what it did.",
|
||||||
|
"Your watchlist is safe. The witnesses, less so.",
|
||||||
|
"Relax. Everything is under control, allegedly.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun randomWelcomeQuote(
|
||||||
|
styleValue: String?,
|
||||||
|
random: Random = Random.Default,
|
||||||
|
): String {
|
||||||
|
val quotes = WelcomeQuotes.getValue(WelcomeQuoteStyle.from(styleValue))
|
||||||
|
return quotes[random.nextInt(quotes.size)]
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loginWelcomeMessage(
|
||||||
|
username: String,
|
||||||
|
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
|
||||||
|
random: Random = Random.Default,
|
||||||
|
): String {
|
||||||
|
val name = username.trim().ifBlank { "there" }
|
||||||
|
return "Welcome to Memby, $name. ${randomWelcomeQuote(styleValue, random)}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.alerts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the
|
||||||
|
* user picker and the summary line on the page itself are the same arithmetic tested once.
|
||||||
|
*
|
||||||
|
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and
|
||||||
|
* not unread ones: an alert that has been read but not dismissed is still sitting there, and
|
||||||
|
* a badge that cleared itself the moment somebody glanced at the page would be a badge that
|
||||||
|
* never agreed with the list underneath it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Above this the badge stops counting and says so, or the pill grows wider than its row. */
|
||||||
|
internal const val AlertBadgeMax = 9
|
||||||
|
|
||||||
|
internal fun alertBadgeLabel(count: Int): String? = when {
|
||||||
|
count <= 0 -> null
|
||||||
|
count > AlertBadgeMax -> "$AlertBadgeMax+"
|
||||||
|
else -> count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line under the page heading. "New" is the unread half — worth naming, since it is the
|
||||||
|
* only thing distinguishing two otherwise identical rows — but it is never claimed on its
|
||||||
|
* own, because a page saying "2 new" above three alerts reads as having lost one.
|
||||||
|
*/
|
||||||
|
internal fun alertsSummary(total: Int, unread: Int): String = when {
|
||||||
|
total <= 0 -> "Nothing waiting for you"
|
||||||
|
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts"
|
||||||
|
else -> {
|
||||||
|
val alerts = if (total == 1) "1 alert" else "$total alerts"
|
||||||
|
"$alerts · $unread new"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.alerts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||||
|
import com.ponzischeme89.memby.data.model.UserNotification
|
||||||
|
import com.ponzischeme89.memby.ui.MembyChoiceChip
|
||||||
|
import com.ponzischeme89.memby.ui.formatMyShowDate
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My Alerts — a full page rather than the panel it used to be, reached from the user menu in
|
||||||
|
* the user picker.
|
||||||
|
*
|
||||||
|
* It moved there because these alerts belong to a *person*, not to a television: they follow
|
||||||
|
* whoever is signed in, so the place that already answers "who is watching" is where somebody
|
||||||
|
* looks for their own news. The bell it replaced sat in the corner of the launcher, was only
|
||||||
|
* drawn on Home, and cost a focus target on every set in the house whether or not there was
|
||||||
|
* anything behind it.
|
||||||
|
*
|
||||||
|
* It reads like Settings on purpose — black canvas, flat rows on a shared 16dp inset with
|
||||||
|
* hairlines between them, and the row under focus the only lit surface on the page.
|
||||||
|
*
|
||||||
|
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
|
||||||
|
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
|
||||||
|
* and a second confirmation press on every alert is what made the old panel not worth
|
||||||
|
* opening. And **focus marks read** — a row can only be read by being looked at, so nothing
|
||||||
|
* has to be pressed to clear the "new" flag on it.
|
||||||
|
*
|
||||||
|
* Stateless by design: the caller owns the list and the requests, so this can be previewed
|
||||||
|
* and screenshotted with no server.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MyAlertsPage(
|
||||||
|
notifications: List<UserNotification>,
|
||||||
|
preferences: NotificationPreferences,
|
||||||
|
onToggleEnabled: () -> Unit,
|
||||||
|
onToggleShowReturns: () -> Unit,
|
||||||
|
onRead: (UserNotification) -> Unit,
|
||||||
|
onDismiss: (UserNotification) -> Unit,
|
||||||
|
onDismissAll: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listFocusRequester = remember { FocusRequester() }
|
||||||
|
val actionsFocusRequester = remember { FocusRequester() }
|
||||||
|
val hasAlerts = notifications.isNotEmpty()
|
||||||
|
LaunchedEffect(hasAlerts) {
|
||||||
|
// One frame for the list to place its first row; an empty page has nothing below
|
||||||
|
// the actions to land on, so the chips take the remote instead.
|
||||||
|
delay(16)
|
||||||
|
runCatching {
|
||||||
|
if (hasAlerts) listFocusRequester.requestFocus() else actionsFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 56.dp)
|
||||||
|
.padding(top = 40.dp, bottom = 28.dp),
|
||||||
|
) {
|
||||||
|
AlertsHeader(
|
||||||
|
total = notifications.size,
|
||||||
|
unread = notifications.count(UserNotification::unread),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().focusGroup(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MembyChoiceChip(
|
||||||
|
label = if (preferences.enabled) "Alerts on" else "Alerts off",
|
||||||
|
selected = preferences.enabled,
|
||||||
|
onClick = onToggleEnabled,
|
||||||
|
modifier = Modifier.focusRequester(actionsFocusRequester),
|
||||||
|
)
|
||||||
|
MembyChoiceChip(
|
||||||
|
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
|
||||||
|
selected = preferences.enabled && preferences.showReturnAlerts,
|
||||||
|
onClick = { if (preferences.enabled) onToggleShowReturns() },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(1.dp))
|
||||||
|
if (hasAlerts) {
|
||||||
|
MembyChoiceChip(
|
||||||
|
label = "Dismiss all",
|
||||||
|
selected = false,
|
||||||
|
onClick = onDismissAll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
||||||
|
if (!hasAlerts) {
|
||||||
|
AlertsEmptyState(enabled = preferences.enabled)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
contentPadding = PaddingValues(vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
items(notifications, key = UserNotification::id) { notification ->
|
||||||
|
AlertRow(
|
||||||
|
notification = notification,
|
||||||
|
modifier = if (notification.id == notifications.first().id) {
|
||||||
|
Modifier.focusRequester(listFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
onFocused = { if (notification.unread) onRead(notification) },
|
||||||
|
onClick = { onDismiss(notification) },
|
||||||
|
)
|
||||||
|
if (notification.id != notifications.last().id) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.height(1.dp)
|
||||||
|
.background(MembyHairline),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertsHeader(total: Int, unread: Int) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertsEmptyState(enabled: Boolean) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
if (enabled) {
|
||||||
|
"Alerts about the shows you follow will show up here."
|
||||||
|
} else {
|
||||||
|
"Alerts are switched off, so nothing new will arrive here."
|
||||||
|
},
|
||||||
|
color = MembyQuietText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertRow(
|
||||||
|
notification: UserNotification,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(MembyCardCorner)
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged {
|
||||||
|
focused = it.isFocused
|
||||||
|
if (it.isFocused) onFocused()
|
||||||
|
}
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
|
||||||
|
.border(
|
||||||
|
width = if (focused) 2.dp else 1.dp,
|
||||||
|
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(38.dp).clip(CircleShape).background(
|
||||||
|
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
alertIcon(notification.kind),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (notification.unread) MembyAccent else MembyQuietText,
|
||||||
|
modifier = Modifier.size(19.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
Text(
|
||||||
|
notification.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (notification.unread) {
|
||||||
|
Text(
|
||||||
|
"NEW",
|
||||||
|
color = MembyAccent,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MembyAccent.copy(alpha = 0.14f))
|
||||||
|
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
notification.message,
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The hint is the whole reason a single press is allowed to dismiss: it is stated on
|
||||||
|
// the row about to go, and only on the row under focus.
|
||||||
|
Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
if (focused) {
|
||||||
|
Text(
|
||||||
|
"OK to dismiss",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertIcon(kind: String): ImageVector = when {
|
||||||
|
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
|
||||||
|
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
|
||||||
|
else -> Icons.Default.NotificationsActive
|
||||||
|
}
|
||||||
@@ -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 couldn’t 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 couldn’t 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 couldn’t decode the original format. Memby will request a compatible stream.",
|
||||||
|
canAutoRetry = true,
|
||||||
|
requiresTranscode = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||||
|
-> PlaybackFailure(
|
||||||
|
title = "Video file couldn’t be read",
|
||||||
|
detail = "This TV couldn’t read the original stream. Memby will request a compatible format.",
|
||||||
|
canAutoRetry = true,
|
||||||
|
requiresTranscode = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||||
|
-> PlaybackFailure(
|
||||||
|
title = "Video file couldn’t be read",
|
||||||
|
detail = "The media server returned a malformed stream.",
|
||||||
|
canAutoRetry = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
Reference in New Issue
Block a user