Compare commits
55 Commits
dev
..
v2026.06.04
| Author | SHA1 | Date | |
|---|---|---|---|
| 301c3bfb86 | |||
| 4d8c7d6566 | |||
| d6e6caa223 | |||
| 8b08482d13 | |||
| 7cf04fe24b | |||
| 222a0ff636 | |||
| d75c1ae37f | |||
| 3c4c27fb08 | |||
| a62a20b599 | |||
| d9b2dd957c | |||
| 46dcd38fd8 | |||
| 7838038047 | |||
| b64965b38d | |||
| bf2f9f3811 | |||
| deb726a285 | |||
| 7ede83a586 | |||
| 1d7b91333f | |||
| 883d416d26 | |||
| 09471a8f5c | |||
| 7de238e91e | |||
| 9440c5860b | |||
| 082d31bfa9 | |||
| 8847b43d9e | |||
| 09cc810e5a | |||
| 2534384ed1 | |||
| 9ff1b30e3f | |||
| c01853577b | |||
| 747ed4134b | |||
| ccbd3b62a0 | |||
| 19de0c2874 | |||
| 22a4649bfc | |||
| 8e7660c05e | |||
| 53be834e89 | |||
| 0d410630a2 | |||
| 62db8edcdb | |||
| ec0cc37bc9 | |||
| e772938a3b | |||
| 29fee5aa37 | |||
| e5ab471ce1 | |||
| 573aa4226d | |||
| 7339815ea9 | |||
| baa601765e | |||
| 0d009b34e2 | |||
| f1b4652c77 | |||
| e610948307 | |||
| fb811804d2 | |||
| 37134950a5 | |||
| fcded9294c | |||
| 42abb7adff | |||
| 04933f2d9f | |||
| 626fc7502c | |||
| 9bf3b8a2f2 | |||
| 492460cf4a | |||
| 1c775905d7 | |||
| cd2ff648d0 |
@@ -2,8 +2,8 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to main → :main and :latest (latest-release APK bundled)
|
# push to main → :main and :latest (no APK bundled)
|
||||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||||
@@ -20,14 +20,6 @@ name: release
|
|||||||
# happens in the same android-release job, so the Release-page download
|
# happens in the same android-release job, so the Release-page download
|
||||||
# link and the in-image bundled APK are both populated atomically.
|
# link and the in-image bundled APK are both populated atomically.
|
||||||
#
|
#
|
||||||
# :latest always carries an APK. Because every main push also moves
|
|
||||||
# :latest (not just tags), a main build with no APK would silently strip
|
|
||||||
# the in-app update channel off :latest until the next release. So on
|
|
||||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
|
||||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
|
||||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
|
||||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
|
||||||
#
|
|
||||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||||
# lives in android.yml and runs independently on every push.
|
# lives in android.yml and runs independently on every push.
|
||||||
|
|
||||||
@@ -189,12 +181,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
# Full history + tags so non-tag :latest builds can resolve the
|
|
||||||
# latest release tag's commit count and reconstruct the bundled
|
|
||||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- name: Detect buildable project
|
- name: Detect buildable project
|
||||||
id: guard
|
id: guard
|
||||||
@@ -235,8 +221,9 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag pushes only — android-release just produced this. Non-tag
|
# Tag pushes only — android-release just produced this. Main
|
||||||
# builds take the "Bundle latest release APK" path below instead.
|
# pushes skip and the image ships with empty client/ (the
|
||||||
|
# /api/client/version endpoint then returns 404 by design).
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -260,42 +247,6 @@ jobs:
|
|||||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
- name: Bundle latest release APK (non-tag :latest builds)
|
|
||||||
# Main pushes don't build an APK, but they DO move :latest — so
|
|
||||||
# without this the in-app update channel would vanish from :latest
|
|
||||||
# until the next tag. Pull the most-recent release's signed APK and
|
|
||||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
|
||||||
# TAG) — identical to android-release's formula) so the version
|
|
||||||
# sidecar the server hands clients matches the installed build.
|
|
||||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
|
||||||
# version — if no release / APK asset / tag-count can be resolved.
|
|
||||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
|
||||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
|
||||||
if [ -z "${REL_JSON}" ]; then
|
|
||||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
|
||||||
fi
|
|
||||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
|
||||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
|
||||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
|
||||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
|
||||||
fi
|
|
||||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
|
||||||
if [ -z "${COUNT}" ]; then
|
|
||||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
|
||||||
fi
|
|
||||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
|
||||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
|
||||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
|
||||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
|
||||||
ls -lh client/
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
|
|
||||||
> State and intelligence belong on the server, not the client.
|
> State and intelligence belong on the server, not the client.
|
||||||
|
|
||||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
<!-- TODO: screenshot of the home page -->
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -24,18 +24,10 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
# Your music library. Point ./music at wherever your audio files
|
|
||||||
# live. Mounted read-only — Minstrel never writes to your library.
|
|
||||||
- ./music:/music:ro
|
- ./music:/music:ro
|
||||||
# Generated data: playlist cover collages, artist art, caches.
|
- minstrel-data:/data
|
||||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
|
||||||
# sets to /app/data — keep this mount on /app/data or your cache
|
|
||||||
# won't survive a container recreate.
|
|
||||||
- minstrel-data:/app/data
|
|
||||||
environment:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||||
# Colon-separated library roots to scan; must match the container
|
|
||||||
# path of the read-only music mount above (/music here).
|
|
||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -45,8 +37,6 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel
|
POSTGRES_DB: minstrel
|
||||||
# Postgres data dir — users, likes, play history, sessions, settings.
|
|
||||||
# The one volume you must never lose; back it up with pg_dump.
|
|
||||||
volumes: [pgdata:/var/lib/postgresql/data]
|
volumes: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,29 +48,7 @@ volumes:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## First run
|
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||||
|
|
||||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
|
||||||
|
|
||||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
|
||||||
|
|
||||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
|
||||||
|
|
||||||
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
|
||||||
|
|
||||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
|
||||||
|
|
||||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
|
||||||
|
|
||||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
|
||||||
|
|
||||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
|
||||||
|
|
||||||
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
|
||||||
|
|
||||||
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
|
||||||
|
|
||||||
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
|
||||||
|
|
||||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||||
|
|
||||||
@@ -89,7 +57,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
|||||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
|
|
||||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
|
|
||||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
@@ -98,13 +66,8 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||||
|
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
|
||||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
|
||||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
|
||||||
|
|
||||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
|
|
||||||
|
|||||||
Generated
-13
@@ -1754,19 +1754,6 @@
|
|||||||
<option name="screenX" value="1600" />
|
<option name="screenX" value="1600" />
|
||||||
<option name="screenY" value="2560" />
|
<option name="screenY" value="2560" />
|
||||||
</PersistentDeviceSelectionData>
|
</PersistentDeviceSelectionData>
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="36" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="formFactor" value="Tablet" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="labId" value="google" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
<PersistentDeviceSelectionData>
|
||||||
<option name="api" value="35" />
|
<option name="api" value="35" />
|
||||||
<option name="brand" value="google" />
|
<option name="brand" value="google" />
|
||||||
|
|||||||
Generated
+1
@@ -1,3 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -1,690 +0,0 @@
|
|||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 6,
|
|
||||||
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "sync_metadata",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "cursor",
|
|
||||||
"columnName": "cursor",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncAt",
|
|
||||||
"columnName": "lastSyncAt",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_artists",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortName",
|
|
||||||
"columnName": "sortName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "mbid",
|
|
||||||
"columnName": "mbid",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistThumbPath",
|
|
||||||
"columnName": "artistThumbPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistFanartPath",
|
|
||||||
"columnName": "artistFanartPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_albums",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistId",
|
|
||||||
"columnName": "artistId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortTitle",
|
|
||||||
"columnName": "sortTitle",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "releaseDate",
|
|
||||||
"columnName": "releaseDate",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "coverPath",
|
|
||||||
"columnName": "coverPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "mbid",
|
|
||||||
"columnName": "mbid",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_tracks",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "albumId",
|
|
||||||
"columnName": "albumId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistId",
|
|
||||||
"columnName": "artistId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "durationMs",
|
|
||||||
"columnName": "durationMs",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "trackNumber",
|
|
||||||
"columnName": "trackNumber",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "discNumber",
|
|
||||||
"columnName": "discNumber",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "filePath",
|
|
||||||
"columnName": "filePath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fileFormat",
|
|
||||||
"columnName": "fileFormat",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "genre",
|
|
||||||
"columnName": "genre",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_likes",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "userId",
|
|
||||||
"columnName": "userId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "entityType",
|
|
||||||
"columnName": "entityType",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "entityId",
|
|
||||||
"columnName": "entityId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "likedAt",
|
|
||||||
"columnName": "likedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"userId",
|
|
||||||
"entityType",
|
|
||||||
"entityId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_playlists",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "userId",
|
|
||||||
"columnName": "userId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "description",
|
|
||||||
"columnName": "description",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isPublic",
|
|
||||||
"columnName": "isPublic",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "coverPath",
|
|
||||||
"columnName": "coverPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "trackCount",
|
|
||||||
"columnName": "trackCount",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "durationSec",
|
|
||||||
"columnName": "durationSec",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "systemVariant",
|
|
||||||
"columnName": "systemVariant",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_playlist_tracks",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "playlistId",
|
|
||||||
"columnName": "playlistId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "trackId",
|
|
||||||
"columnName": "trackId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "position",
|
|
||||||
"columnName": "position",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"playlistId",
|
|
||||||
"trackId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_quarantine_mine",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "trackId",
|
|
||||||
"columnName": "trackId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "reason",
|
|
||||||
"columnName": "reason",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "notes",
|
|
||||||
"columnName": "notes",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAt",
|
|
||||||
"columnName": "createdAt",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "trackTitle",
|
|
||||||
"columnName": "trackTitle",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "trackDurationMs",
|
|
||||||
"columnName": "trackDurationMs",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "albumId",
|
|
||||||
"columnName": "albumId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "albumTitle",
|
|
||||||
"columnName": "albumTitle",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "albumCoverArtPath",
|
|
||||||
"columnName": "albumCoverArtPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistId",
|
|
||||||
"columnName": "artistId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "artistName",
|
|
||||||
"columnName": "artistName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"trackId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "audio_cache_index",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "trackId",
|
|
||||||
"columnName": "trackId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "path",
|
|
||||||
"columnName": "path",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sizeBytes",
|
|
||||||
"columnName": "sizeBytes",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "cachedAt",
|
|
||||||
"columnName": "cachedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastPlayedAt",
|
|
||||||
"columnName": "lastPlayedAt",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "source",
|
|
||||||
"columnName": "source",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"trackId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_mutations",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "kind",
|
|
||||||
"columnName": "kind",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "payload",
|
|
||||||
"columnName": "payload",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAt",
|
|
||||||
"columnName": "createdAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastAttemptAt",
|
|
||||||
"columnName": "lastAttemptAt",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "attempts",
|
|
||||||
"columnName": "attempts",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_resume_state",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "json",
|
|
||||||
"columnName": "json",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "updatedAt",
|
|
||||||
"columnName": "updatedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_home_index",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "section",
|
|
||||||
"columnName": "section",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "position",
|
|
||||||
"columnName": "position",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "entityType",
|
|
||||||
"columnName": "entityType",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "entityId",
|
|
||||||
"columnName": "entityId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fetchedAt",
|
|
||||||
"columnName": "fetchedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"section",
|
|
||||||
"position"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_history_snapshot",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "json",
|
|
||||||
"columnName": "json",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "updatedAt",
|
|
||||||
"columnName": "updatedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "auth_session",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sessionCookie",
|
|
||||||
"columnName": "sessionCookie",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "baseUrl",
|
|
||||||
"columnName": "baseUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "userJson",
|
|
||||||
"columnName": "userJson",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "themeMode",
|
|
||||||
"columnName": "themeMode",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "clientId",
|
|
||||||
"columnName": "clientId",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "cacheSettingsJson",
|
|
||||||
"columnName": "cacheSettingsJson",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
|||||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
@@ -41,7 +41,7 @@ import javax.inject.Inject
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||||
@Inject lateinit var serverHealth: NetworkStatusController
|
@Inject lateinit var serverHealth: ServerHealthController
|
||||||
|
|
||||||
// Flipped to true when the user taps the media notification (or
|
// Flipped to true when the user taps the media notification (or
|
||||||
// any other entry point that asks for the full player). The App
|
// any other entry point that asks for the full player). The App
|
||||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun App(
|
private fun App(
|
||||||
seedCache: DetailSeedCache,
|
seedCache: DetailSeedCache,
|
||||||
cachedTrackIds: CachedTrackIds,
|
cachedTrackIds: CachedTrackIds,
|
||||||
serverHealth: NetworkStatusController,
|
serverHealth: ServerHealthController,
|
||||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||||
onOpenedNowPlaying: () -> Unit,
|
onOpenedNowPlaying: () -> Unit,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import com.fabledsword.minstrel.cache.CacheIndexer
|
|||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter
|
|
||||||
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
@@ -21,7 +19,8 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
|||||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||||
|
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -116,13 +115,21 @@ class MinstrelApplication :
|
|||||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
* Same construct-the-singleton trick — VersionCheckController's
|
||||||
* /healthz poll loop + the device-link collector + the reachability state
|
* init block starts a 5-min poll loop against /healthz so the
|
||||||
* machine, and is the single authority on the tri-state ServerHealth
|
* shell-level VersionTooOldBanner can surface min_client_version
|
||||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
* mismatches without waiting for the next user-driven request.
|
||||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same construct-the-singleton trick — ServerHealthController combines
|
||||||
|
* ConnectivityObserver + VersionCheckController.reachable into the
|
||||||
|
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
|
||||||
|
* so the StateFlow needs an active subscriber from launch onward; the
|
||||||
|
* @Inject keeps the singleton alive and the flow collecting.
|
||||||
|
*/
|
||||||
|
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||||
@@ -157,11 +164,6 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||||
|
|
||||||
// Device diagnostics (M9). The reporter gates itself on the account's
|
|
||||||
// debug flag; the uploader drains its buffer on a tick / recovery.
|
|
||||||
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
|
||||||
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
|
||||||
|
|
||||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.admin.data
|
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
|
||||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
|
||||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
|
||||||
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
|
||||||
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.create
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
|
||||||
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
|
||||||
* mutations fire direct REST and the ViewModel reconciles on failure.
|
|
||||||
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class AdminTagSourcesRepository @Inject constructor(
|
|
||||||
retrofit: Retrofit,
|
|
||||||
) {
|
|
||||||
private val api: AdminTagSourcesApi = retrofit.create()
|
|
||||||
|
|
||||||
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
|
||||||
|
|
||||||
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
|
||||||
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
|
||||||
|
|
||||||
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
|
||||||
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
|
||||||
|
|
||||||
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
|
||||||
id = id,
|
|
||||||
displayName = displayName,
|
|
||||||
requiresApiKey = requiresApiKey,
|
|
||||||
supports = supports,
|
|
||||||
enabled = enabled,
|
|
||||||
apiKeySet = apiKeySet,
|
|
||||||
testable = testable,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
|
||||||
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
|
||||||
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.models.Invite
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -27,7 +25,6 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -39,13 +36,6 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
|
||||||
// when server health returns instead of waiting for a manual pull.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value.message != null) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@@ -28,18 +28,15 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -57,7 +54,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
||||||
|
|
||||||
sealed interface AdminLandingUiState {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -72,7 +69,6 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
private val tagSourcesRepo: AdminTagSourcesRepository,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -89,8 +85,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
AdminCounts(req.await(), qua.await(), usr.await())
|
||||||
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
|
||||||
}
|
}
|
||||||
internal.value = AdminLandingUiState.Success(counts)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -175,15 +170,6 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
SectionCard(
|
|
||||||
icon = Lucide.Music,
|
|
||||||
title = "Tag sources",
|
|
||||||
subtitle = "Metadata enrichment providers",
|
|
||||||
count = counts.tagSources,
|
|
||||||
onClick = { navController.navigate(AdminTagSources) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -29,7 +29,6 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -63,10 +62,9 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> ErrorRetry(
|
is AdminQuarantineUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
-10
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -28,7 +26,6 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -41,13 +38,6 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
|
||||||
// when server health returns instead of waiting for a manual pull.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,10 +61,9 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
is AdminRequestsUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -37,7 +35,6 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -50,13 +47,6 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
|
||||||
// when server health returns instead of waiting for a manual pull.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is AdminRequestsUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.admin.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
|
||||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
|
||||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AdminTagSourcesScreen(
|
|
||||||
navController: NavHostController,
|
|
||||||
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
MinstrelTopAppBar(
|
|
||||||
title = "Admin · Tag sources",
|
|
||||||
navController = navController,
|
|
||||||
currentRouteName = AdminTagSources::class.qualifiedName,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { inner ->
|
|
||||||
PullToRefreshScaffold(
|
|
||||||
onRefresh = { viewModel.refresh().join() },
|
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
|
||||||
) {
|
|
||||||
when (val s = state) {
|
|
||||||
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
|
||||||
AdminTagSourcesUiState.Empty -> EmptyState(
|
|
||||||
title = "No tag sources",
|
|
||||||
body = "Tag-enrichment providers register on the server; none are available.",
|
|
||||||
)
|
|
||||||
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
|
||||||
title = "Couldn't load tag sources",
|
|
||||||
message = s.message,
|
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
|
||||||
is AdminTagSourcesUiState.Success -> TagSourceList(
|
|
||||||
providers = s.providers,
|
|
||||||
testResults = s.testResults,
|
|
||||||
onToggle = viewModel::setEnabled,
|
|
||||||
onSaveKey = viewModel::saveApiKey,
|
|
||||||
onTest = viewModel::test,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TagSourceList(
|
|
||||||
providers: List<AdminTagSourceRef>,
|
|
||||||
testResults: Map<String, TagSourceTestResult>,
|
|
||||||
onToggle: (String, Boolean) -> Unit,
|
|
||||||
onSaveKey: (String, String) -> Unit,
|
|
||||||
onTest: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
items(items = providers, key = { it.id }) { provider ->
|
|
||||||
TagSourceCard(
|
|
||||||
provider = provider,
|
|
||||||
testResult = testResults[provider.id],
|
|
||||||
onToggle = onToggle,
|
|
||||||
onSaveKey = onSaveKey,
|
|
||||||
onTest = onTest,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TagSourceCard(
|
|
||||||
provider: AdminTagSourceRef,
|
|
||||||
testResult: TagSourceTestResult?,
|
|
||||||
onToggle: (String, Boolean) -> Unit,
|
|
||||||
onSaveKey: (String, String) -> Unit,
|
|
||||||
onTest: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = provider.enabled,
|
|
||||||
onCheckedChange = { onToggle(provider.id, it) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (provider.requiresApiKey) {
|
|
||||||
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
|
||||||
}
|
|
||||||
if (provider.testable) {
|
|
||||||
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
|
||||||
var key by remember(provider.id) { mutableStateOf("") }
|
|
||||||
OutlinedTextField(
|
|
||||||
value = key,
|
|
||||||
onValueChange = { key = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("API key") },
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
if (provider.apiKeySet) {
|
|
||||||
"••• saved — leave blank to keep"
|
|
||||||
} else {
|
|
||||||
"Paste your API key to enable this source"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSaveKey(provider.id, key)
|
|
||||||
key = ""
|
|
||||||
},
|
|
||||||
enabled = key.isNotBlank(),
|
|
||||||
) { Text("Save key") }
|
|
||||||
if (provider.apiKeySet) {
|
|
||||||
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TestRow(
|
|
||||||
provider: AdminTagSourceRef,
|
|
||||||
testResult: TagSourceTestResult?,
|
|
||||||
onTest: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
|
||||||
testResult?.let { result ->
|
|
||||||
if (result.ok) {
|
|
||||||
Text(
|
|
||||||
text = "OK (${result.durationMs}ms)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Failed — ${result.error}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.admin.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
|
||||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
sealed interface AdminTagSourcesUiState {
|
|
||||||
data object Loading : AdminTagSourcesUiState
|
|
||||||
data object Empty : AdminTagSourcesUiState
|
|
||||||
data class Success(
|
|
||||||
val providers: List<AdminTagSourceRef>,
|
|
||||||
val testResults: Map<String, TagSourceTestResult>,
|
|
||||||
) : AdminTagSourcesUiState
|
|
||||||
data class Error(val message: String) : AdminTagSourcesUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class AdminTagSourcesViewModel @Inject constructor(
|
|
||||||
private val repository: AdminTagSourcesRepository,
|
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
|
|
||||||
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
refresh()
|
|
||||||
// Screen-level auto-recovery: reload a failed list when server
|
|
||||||
// health returns instead of waiting for a manual pull (issue #1245).
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
|
||||||
internal.value = AdminTagSourcesUiState.Loading
|
|
||||||
try {
|
|
||||||
val providers = repository.list()
|
|
||||||
internal.value = if (providers.isEmpty()) {
|
|
||||||
AdminTagSourcesUiState.Empty
|
|
||||||
} else {
|
|
||||||
AdminTagSourcesUiState.Success(providers, emptyMap())
|
|
||||||
}
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEnabled(id: String, enabled: Boolean) {
|
|
||||||
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
|
|
||||||
// Optimistic toggle; reconcile via refresh() if the server rejects.
|
|
||||||
internal.value = before.copy(
|
|
||||||
providers = before.providers.map {
|
|
||||||
if (it.id == id) it.copy(enabled = enabled) else it
|
|
||||||
},
|
|
||||||
)
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
repository.setEnabled(id, enabled)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveApiKey(id: String, apiKey: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
replaceProvider(repository.setApiKey(id, apiKey))
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun test(id: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result = try {
|
|
||||||
repository.test(id)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
|
|
||||||
}
|
|
||||||
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
|
|
||||||
internal.value = current.copy(testResults = current.testResults + (id to result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replaceProvider(updated: AdminTagSourceRef) {
|
|
||||||
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
|
|
||||||
internal.value = current.copy(
|
|
||||||
providers = current.providers.map { if (it.id == updated.id) updated else it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -25,7 +23,6 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -33,13 +30,6 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
|
||||||
// when server health returns instead of waiting for a manual pull.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is AdminUsersUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -46,15 +45,9 @@ object NetworkModule {
|
|||||||
fun provideOkHttp(
|
fun provideOkHttp(
|
||||||
baseUrl: BaseUrlInterceptor,
|
baseUrl: BaseUrlInterceptor,
|
||||||
auth: AuthCookieInterceptor,
|
auth: AuthCookieInterceptor,
|
||||||
reachability: ReachabilityReportingInterceptor,
|
|
||||||
logging: HttpLoggingInterceptor,
|
logging: HttpLoggingInterceptor,
|
||||||
): OkHttpClient =
|
): OkHttpClient =
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
|
||||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
|
||||||
// (so external artwork fetches don't read as server reachability)
|
|
||||||
// and observes the final transport outcome by wrapping the chain.
|
|
||||||
.addInterceptor(reachability)
|
|
||||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||||
// Minstrel-server requests from external image fetches
|
// Minstrel-server requests from external image fetches
|
||||||
|
|||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.api.endpoints
|
|
||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
|
||||||
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
|
|
||||||
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.PATCH
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
|
||||||
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
|
||||||
* web integrations "Tag enrichment sources" card. Same shape as the
|
|
||||||
* cover-sources admin surface; kept independent so a new tag source is
|
|
||||||
* added without touching art settings.
|
|
||||||
*/
|
|
||||||
interface AdminTagSourcesApi {
|
|
||||||
@GET("api/admin/tag-sources")
|
|
||||||
suspend fun list(): AdminTagSourcesListWire
|
|
||||||
|
|
||||||
@PATCH("api/admin/tag-sources/{id}")
|
|
||||||
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
|
|
||||||
|
|
||||||
@POST("api/admin/tag-sources/{id}/test")
|
|
||||||
suspend fun test(@Path("id") id: String): TestTagSourceWire
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
|
|
||||||
* untouched one (the server reads a missing/null field as "leave
|
|
||||||
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class UpdateTagSourceBody(
|
|
||||||
val enabled: Boolean? = null,
|
|
||||||
@SerialName("api_key") val apiKey: String? = null,
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.api.endpoints
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrofit interface for device diagnostics ingest (M9). One call
|
|
||||||
* uploads a batch of buffered events. The server stores them only when
|
|
||||||
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
|
||||||
* which the uploader treats as success so it can drop the batch).
|
|
||||||
*/
|
|
||||||
interface DiagnosticsApi {
|
|
||||||
@POST("api/diagnostics")
|
|
||||||
suspend fun report(@Body body: DiagnosticsReportRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DiagnosticsReportRequest(
|
|
||||||
@SerialName("client_id") val clientId: String,
|
|
||||||
@SerialName("app_version") val appVersion: String? = null,
|
|
||||||
@SerialName("os_version") val osVersion: String? = null,
|
|
||||||
val events: List<DiagnosticEventWire>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DiagnosticEventWire(
|
|
||||||
val kind: String,
|
|
||||||
// Device-clock epoch milliseconds. The server stamps its own
|
|
||||||
// received_at; both are stored so a skewed device clock is visible.
|
|
||||||
@SerialName("occurred_at") val occurredAt: Long,
|
|
||||||
// Opaque structured payload carrying the event sub-type + fields.
|
|
||||||
val payload: JsonElement,
|
|
||||||
)
|
|
||||||
@@ -2,7 +2,6 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
|
||||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -36,26 +35,9 @@ interface LibraryApi {
|
|||||||
@GET("api/artists/{id}/tracks")
|
@GET("api/artists/{id}/tracks")
|
||||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||||
|
|
||||||
@GET("api/artists/{id}/similar")
|
|
||||||
suspend fun getSimilarArtists(
|
|
||||||
@Path("id") id: String,
|
|
||||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
|
||||||
): List<ArtistWire>
|
|
||||||
|
|
||||||
@GET("api/artists/{id}/top-tracks")
|
|
||||||
suspend fun getArtistTopTracks(
|
|
||||||
@Path("id") id: String,
|
|
||||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
|
||||||
): List<TrackWire>
|
|
||||||
|
|
||||||
@GET("api/albums/{id}")
|
@GET("api/albums/{id}")
|
||||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||||
|
|
||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
|
||||||
const val TOP_TRACKS_LIMIT = 5
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.auth
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.UserRef
|
import com.fabledsword.minstrel.models.UserRef
|
||||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||||
@@ -36,7 +35,6 @@ class AuthController @Inject constructor(
|
|||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: AuthApi = retrofit.create()
|
private val api: AuthApi = retrofit.create()
|
||||||
private val meApi: MeApi = retrofit.create()
|
|
||||||
|
|
||||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||||
@@ -57,10 +55,6 @@ class AuthController @Inject constructor(
|
|||||||
currentUserState.value = raw?.let { decodeUser(it) }
|
currentUserState.value = raw?.let { decodeUser(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh from /api/me on startup so a remotely-changed account
|
|
||||||
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
|
||||||
// without a re-login. Best-effort; failures keep the cached value.
|
|
||||||
scope.launch { refreshProfile() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,32 +72,9 @@ class AuthController @Inject constructor(
|
|||||||
)
|
)
|
||||||
currentUserState.value = user
|
currentUserState.value = user
|
||||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
// Pull the fuller /me shape (carries debug_mode_enabled) right
|
|
||||||
// after login so the diagnostics gate is correct without waiting
|
|
||||||
// for the next startup refresh.
|
|
||||||
scope.launch { refreshProfile() }
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-fetch the caller's profile from /api/me and update currentUser
|
|
||||||
* + persisted userJson. Carries account-level flags (debug mode)
|
|
||||||
* that aren't in the login response. Best-effort: a network failure
|
|
||||||
* leaves the cached identity untouched. No-op when signed out.
|
|
||||||
*/
|
|
||||||
suspend fun refreshProfile() {
|
|
||||||
if (!isSignedIn) return
|
|
||||||
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
|
||||||
val user = UserRef(
|
|
||||||
id = p.id,
|
|
||||||
username = p.username,
|
|
||||||
isAdmin = p.isAdmin,
|
|
||||||
debugModeEnabled = p.debugModeEnabled,
|
|
||||||
)
|
|
||||||
currentUserState.value = user
|
|
||||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the local session immediately and best-effort hits
|
* Clears the local session immediately and best-effort hits
|
||||||
* `/api/auth/logout` so the server can drop its session row.
|
* `/api/auth/logout` so the server can drop its session row.
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ class AuthStore @Inject constructor(
|
|||||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||||
|
|
||||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
|
||||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -73,7 +70,6 @@ class AuthStore @Inject constructor(
|
|||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,11 +112,6 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistCacheSettings(encoded) }
|
scope.launch { persistCacheSettings(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDiagnosticsOptOut(value: Boolean) {
|
|
||||||
diagnosticsOptOutState.value = value
|
|
||||||
scope.launch { persistDiagnosticsOptOut(value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -169,14 +160,6 @@ class AuthStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun persistDiagnosticsOptOut(value: Boolean) {
|
|
||||||
if (dao.get() == null) {
|
|
||||||
dao.upsert(currentEntity().copy(diagnosticsOptOut = value))
|
|
||||||
} else {
|
|
||||||
dao.setDiagnosticsOptOut(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
sessionCookie = sessionCookieState.value,
|
||||||
@@ -188,7 +171,6 @@ class AuthStore @Inject constructor(
|
|||||||
CacheSettings.serializer(),
|
CacheSettings.serializer(),
|
||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
@@ -31,7 +30,6 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,11 +61,8 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
|
||||||
],
|
],
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
version = 6,
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
|
||||||
version = 7,
|
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -86,5 +81,4 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
@@ -106,10 +105,5 @@ object DatabaseModule {
|
|||||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||||
db.audioCacheIndexDao()
|
db.audioCacheIndexDao()
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
|
||||||
db.diagnosticEventDao()
|
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -42,8 +42,4 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized cache settings. */
|
/** Partial update: change only the serialized cache settings. */
|
||||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||||
suspend fun setCacheSettingsJson(json: String?)
|
suspend fun setCacheSettingsJson(json: String?)
|
||||||
|
|
||||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
|
||||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
|
||||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
|
||||||
}
|
}
|
||||||
|
|||||||
-14
@@ -31,20 +31,6 @@ interface CachedPlaylistDao {
|
|||||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-playlist count of member tracks resident in the audio cache index.
|
|
||||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
|
||||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
|
||||||
"FROM cached_playlists p " +
|
|
||||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
|
||||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
|
||||||
"GROUP BY p.id",
|
|
||||||
)
|
|
||||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||||
|
|
||||||
|
|||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.dao
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.Query
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface DiagnosticEventDao {
|
|
||||||
@Insert
|
|
||||||
suspend fun insert(row: DiagnosticEventEntity): Long
|
|
||||||
|
|
||||||
/** FIFO drain order so the uploader sends oldest-first. */
|
|
||||||
@Query("SELECT * FROM diagnostic_events ORDER BY id ASC LIMIT :limit")
|
|
||||||
suspend fun takeBatch(limit: Int): List<DiagnosticEventEntity>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM diagnostic_events")
|
|
||||||
suspend fun count(): Int
|
|
||||||
|
|
||||||
@Query("DELETE FROM diagnostic_events WHERE id IN (:ids)")
|
|
||||||
suspend fun deleteByIds(ids: List<Long>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ring-buffer trim: drop the oldest rows beyond [keep]. Called after
|
|
||||||
* insert so a long offline stretch can't grow the buffer unbounded.
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"DELETE FROM diagnostic_events WHERE id NOT IN " +
|
|
||||||
"(SELECT id FROM diagnostic_events ORDER BY id DESC LIMIT :keep)",
|
|
||||||
)
|
|
||||||
suspend fun trimToNewest(keep: Int)
|
|
||||||
|
|
||||||
@Query("DELETE FROM diagnostic_events")
|
|
||||||
suspend fun clear()
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.dao
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Projection: how many of a playlist's member tracks are resident in the audio
|
|
||||||
* cache index. Backs the offline "fully cached" greying — a playlist is fully
|
|
||||||
* available offline when [cachedCount] reaches its `trackCount`.
|
|
||||||
*/
|
|
||||||
data class PlaylistCachedCount(
|
|
||||||
val playlistId: String,
|
|
||||||
val cachedCount: Int,
|
|
||||||
)
|
|
||||||
-7
@@ -36,11 +36,4 @@ data class AuthSessionEntity(
|
|||||||
* CacheSettings shape evolves.
|
* CacheSettings shape evolves.
|
||||||
*/
|
*/
|
||||||
val cacheSettingsJson: String? = null,
|
val cacheSettingsJson: String? = null,
|
||||||
/**
|
|
||||||
* Per-device opt-out of diagnostics reporting (M9). When the
|
|
||||||
* account's debug mode is enabled by an admin, the user can still
|
|
||||||
* turn reporting OFF on this device (battery/privacy) — that local
|
|
||||||
* choice lives here. Default false = honor the account flag.
|
|
||||||
*/
|
|
||||||
val diagnosticsOptOut: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
-5
@@ -14,11 +14,6 @@ import kotlinx.datetime.Instant
|
|||||||
* - "rediscover_artists"
|
* - "rediscover_artists"
|
||||||
* - "most_played_tracks"
|
* - "most_played_tracks"
|
||||||
* - "last_played_artists"
|
* - "last_played_artists"
|
||||||
* - "you_might_like_albums"
|
|
||||||
* - "you_might_like_artists"
|
|
||||||
*
|
|
||||||
* New `section` values need no schema change — the column stores the
|
|
||||||
* string verbatim and the DAO keys on it.
|
|
||||||
*
|
*
|
||||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||||
* to the right per-entity endpoint when a tile is rendered.
|
* to the right per-entity endpoint when a tile is rendered.
|
||||||
|
|||||||
Vendored
-28
@@ -1,28 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
|
|
||||||
* writes rows here when the account's debug mode is on; the
|
|
||||||
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
|
||||||
* on success.
|
|
||||||
*
|
|
||||||
* This is a deliberate ring buffer that is NOT routed through the offline
|
|
||||||
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
|
||||||
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
|
|
||||||
* offline — so events must persist locally through the dead zone and
|
|
||||||
* upload on recovery, without clogging the user-data mutation replay.
|
|
||||||
*
|
|
||||||
* `occurredAtMillis` is the device-clock epoch-ms when the event happened
|
|
||||||
* (the server also stamps its own received_at). `payloadJson` is an
|
|
||||||
* opaque JSON object carrying the event sub-type + fields.
|
|
||||||
*/
|
|
||||||
@Entity(tableName = "diagnostic_events")
|
|
||||||
data class DiagnosticEventEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
||||||
val kind: String,
|
|
||||||
val occurredAtMillis: Long,
|
|
||||||
val payloadJson: String,
|
|
||||||
)
|
|
||||||
-34
@@ -28,13 +28,6 @@ object MutationKind {
|
|||||||
const val PLAY_OFFLINE: String = "play_offline"
|
const val PLAY_OFFLINE: String = "play_offline"
|
||||||
const val REQUEST_CANCEL: String = "request_cancel"
|
const val REQUEST_CANCEL: String = "request_cancel"
|
||||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||||
|
|
||||||
// Durable close-by-id for a play whose server row is already open
|
|
||||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
|
||||||
// a *whole* play when no server row exists. Using close-by-id on
|
|
||||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
|
||||||
// path produced (see 2026-06-11 contract audit).
|
|
||||||
const val PLAY_ENDED: String = "play_ended"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,17 +167,6 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Durable close-by-id for an already-open server play row. Background
|
|
||||||
* path only (no user hint) — same as [enqueuePlayOffline].
|
|
||||||
*/
|
|
||||||
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
|
|
||||||
CachedMutationEntity(
|
|
||||||
kind = MutationKind.PLAY_ENDED,
|
|
||||||
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||||
@@ -239,22 +221,6 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
// #1551: device class for context conditioning; null on payloads queued
|
|
||||||
// before this field existed (decodes to null → server stores NULL).
|
|
||||||
val deviceClass: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
|
||||||
* `POST /api/events` with type=play_ended, closing the already-open server
|
|
||||||
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
|
||||||
* idempotent (re-ending updates the same row and skips a duplicate
|
|
||||||
* skip_events insert), so a lost-response retry can't duplicate history.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayEndedPayload(
|
|
||||||
val playEventId: String,
|
|
||||||
val durationPlayedMs: Long,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+115
-129
@@ -1,5 +1,3 @@
|
|||||||
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
|
||||||
|
|
||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||||
@@ -12,9 +10,6 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
|
||||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
@@ -24,16 +19,12 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
|
|||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
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
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.serialization.SerializationException
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.HttpException
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -43,24 +34,25 @@ import javax.inject.Singleton
|
|||||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||||
* resolves each to an [Outcome]:
|
* deletes the row on 2xx success.
|
||||||
* - SENT — server accepted (2xx) → delete the row.
|
|
||||||
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
|
|
||||||
* payload) → delete the row so it can't wedge the queue forever.
|
|
||||||
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
|
|
||||||
* attempts, retry on the next trigger.
|
|
||||||
*
|
*
|
||||||
* Triggered by:
|
* Triggered by:
|
||||||
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
* - app start (MinstrelApplication @Inject forces the singleton's
|
||||||
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
* init block to subscribe)
|
||||||
* start with a persisted cookie)
|
* - every time AuthStore.sessionCookie transitions to a non-null
|
||||||
* - NetworkStatusController recovering to Healthy (server reachable again
|
* value (sign-in, app launch with persisted cookie)
|
||||||
* mid-session — covers offline→online without needing a cold start)
|
|
||||||
*
|
*
|
||||||
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
* Single in-flight via the `mutex` — back-to-back triggers collapse
|
||||||
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
* into one drain pass. A proper connectivity-listener / WorkManager
|
||||||
* typical use, and permanent failures are now dropped rather than retried
|
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
||||||
* forever.
|
* sign-in + app-open triggers cover the common offline → online
|
||||||
|
* transition (operator unlocks phone, opens app).
|
||||||
|
*
|
||||||
|
* Failed rows stay in the queue with their `attempts` and
|
||||||
|
* `lastAttemptAt` updated. They get another chance on the next
|
||||||
|
* trigger; no exponential backoff or max-attempts yet — the rows are
|
||||||
|
* small (low single-digit count for typical use) so retry-forever is
|
||||||
|
* cheap.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MutationReplayer @Inject constructor(
|
class MutationReplayer @Inject constructor(
|
||||||
@@ -68,7 +60,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val likesApi: LikesApi = retrofit.create()
|
private val likesApi: LikesApi = retrofit.create()
|
||||||
@@ -81,8 +72,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private enum class Outcome { SENT, DROP, RETRY }
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -90,16 +79,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { drainSafe() }
|
.collect { drainSafe() }
|
||||||
}
|
}
|
||||||
// Server-reachable-again: replay queued writes the moment the
|
|
||||||
// health signal recovers, so an offline→online transition mid-
|
|
||||||
// session doesn't wait for the next cold start / re-auth.
|
|
||||||
scope.launch {
|
|
||||||
networkStatus.state
|
|
||||||
.map { it == ServerHealth.Healthy }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.filter { it }
|
|
||||||
.collect { drainSafe() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||||
@@ -114,98 +93,69 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
val rows = dao.getAll()
|
||||||
// Collapse superseded like-toggles: only the latest desired state per
|
|
||||||
// (entity) is replayed; older toggles for the same entity are dropped
|
|
||||||
// unsent. Without this, partial-failure + differential retry could
|
|
||||||
// replay an older toggle last and invert the final like state.
|
|
||||||
val superseded = supersededLikeToggleIds(rows)
|
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
if (row.id in superseded) {
|
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
||||||
|
if (sent) {
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
continue
|
} else {
|
||||||
}
|
dao.recordAttempt(row.id, Clock.System.now())
|
||||||
when (outcomeFor(row)) {
|
|
||||||
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
|
||||||
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
|
||||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
|
||||||
val latestByEntity = HashMap<String, Long>()
|
|
||||||
val superseded = HashSet<Long>()
|
|
||||||
rows.asSequence()
|
|
||||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
|
||||||
.forEach { row ->
|
|
||||||
val decoded = runCatching {
|
|
||||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
|
||||||
}.getOrNull()
|
|
||||||
if (decoded != null) {
|
|
||||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
|
||||||
// `rows` is ascending by id, so a prior entry is always older.
|
|
||||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return superseded
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
|
||||||
dispatch(row)
|
|
||||||
} catch (e: HttpException) {
|
|
||||||
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
|
|
||||||
} catch (@Suppress("SwallowedException") e: SerializationException) {
|
|
||||||
// Corrupt / schema-incompatible payload — permanent, drop it.
|
|
||||||
Outcome.DROP
|
|
||||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
|
|
||||||
// Transport / unknown — leave queued for the next pass.
|
|
||||||
Outcome.RETRY
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 4xx are permanent except the two "retry me" statuses. */
|
|
||||||
private fun isPermanent(code: Int): Boolean =
|
|
||||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
|
||||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
* Returns true if the server accepted the call, false on any
|
||||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
* failure (transport, server error, decode). The replayer treats
|
||||||
* by [outcomeFor] — dispatchers no longer swallow.
|
* false as "leave the row for next pass" — it doesn't try to
|
||||||
|
* distinguish permanent rejections from transient ones; a row
|
||||||
|
* that consistently fails will accumulate attempt count and
|
||||||
|
* lastAttemptAt for diagnostics.
|
||||||
*/
|
*/
|
||||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
||||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
else -> {
|
||||||
else -> Outcome.DROP
|
// Unknown kind — drop the row by claiming success so a
|
||||||
|
// stale schema entry can't wedge the queue forever.
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||||
val kindPath = when (decoded.entityType) {
|
val kindPath = when (decoded.entityType) {
|
||||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||||
else -> return Outcome.DROP
|
else -> return true // unknown variant — drop.
|
||||||
}
|
}
|
||||||
|
return try {
|
||||||
if (decoded.desiredState) {
|
if (decoded.desiredState) {
|
||||||
likesApi.like(kindPath, decoded.entityId)
|
likesApi.like(kindPath, decoded.entityId)
|
||||||
} else {
|
} else {
|
||||||
likesApi.unlike(kindPath, decoded.entityId)
|
likesApi.unlike(kindPath, decoded.entityId)
|
||||||
}
|
}
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: the row stays in the queue with
|
||||||
|
// attempts + lastAttemptAt incremented; next drain pass
|
||||||
|
// retries. Per-attempt diagnostic logging lands when we
|
||||||
|
// wire up Timber at the replayer level.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchRequestCreate(payload: String): Outcome {
|
private suspend fun dispatchRequestCreate(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||||
discoverApi.createRequest(
|
val body = CreateRequestBody(
|
||||||
CreateRequestBody(
|
|
||||||
kind = decoded.kind,
|
kind = decoded.kind,
|
||||||
artistMbid = decoded.artistMbid,
|
artistMbid = decoded.artistMbid,
|
||||||
artistName = decoded.artistName,
|
artistName = decoded.artistName,
|
||||||
@@ -213,28 +163,56 @@ class MutationReplayer @Inject constructor(
|
|||||||
albumTitle = decoded.albumTitle,
|
albumTitle = decoded.albumTitle,
|
||||||
trackMbid = decoded.trackMbid,
|
trackMbid = decoded.trackMbid,
|
||||||
trackTitle = decoded.trackTitle,
|
trackTitle = decoded.trackTitle,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
return try {
|
||||||
|
discoverApi.createRequest(body)
|
||||||
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: the row stays in the queue with
|
||||||
|
// attempts + lastAttemptAt incremented; next drain pass
|
||||||
|
// retries. Per-attempt diagnostic logging lands when we
|
||||||
|
// wire up Timber at the replayer level.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
quarantineApi.unflag(decoded.trackId)
|
quarantineApi.unflag(decoded.trackId)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: the row stays in the queue with
|
||||||
|
// attempts + lastAttemptAt incremented; next drain pass
|
||||||
|
// retries. Per-attempt diagnostic logging lands when we
|
||||||
|
// wire up Timber at the replayer level.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
playlistsApi.appendTracks(
|
playlistsApi.appendTracks(
|
||||||
playlistId = decoded.playlistId,
|
playlistId = decoded.playlistId,
|
||||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
quarantineApi.flag(
|
quarantineApi.flag(
|
||||||
FlagRequest(
|
FlagRequest(
|
||||||
trackId = decoded.trackId,
|
trackId = decoded.trackId,
|
||||||
@@ -242,11 +220,18 @@ class MutationReplayer @Inject constructor(
|
|||||||
notes = decoded.notes,
|
notes = decoded.notes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
eventsApi.playOffline(
|
eventsApi.playOffline(
|
||||||
PlayOfflineRequest(
|
PlayOfflineRequest(
|
||||||
trackId = decoded.trackId,
|
trackId = decoded.trackId,
|
||||||
@@ -254,31 +239,33 @@ class MutationReplayer @Inject constructor(
|
|||||||
at = decoded.atIso,
|
at = decoded.atIso,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
source = decoded.source,
|
source = decoded.source,
|
||||||
deviceClass = decoded.deviceClass,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlayEnded(payload: String): Outcome {
|
private suspend fun dispatchRequestCancel(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
|
|
||||||
eventsApi.playEnded(
|
|
||||||
PlayEndedRequest(
|
|
||||||
playEventId = decoded.playEventId,
|
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Outcome.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun dispatchRequestCancel(payload: String): Outcome {
|
|
||||||
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
requestsApi.cancel(decoded.requestId)
|
requestsApi.cancel(decoded.requestId)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
|
return try {
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
PlaybackErrorReportRequest(
|
PlaybackErrorReportRequest(
|
||||||
trackId = decoded.trackId,
|
trackId = decoded.trackId,
|
||||||
@@ -287,13 +274,12 @@ class MutationReplayer @Inject constructor(
|
|||||||
clientId = decoded.clientId,
|
clientId = decoded.clientId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
true
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: row stays queued; next drain pass retries.
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val HTTP_CLIENT_ERR_MIN = 400
|
|
||||||
const val HTTP_CLIENT_ERR_MAX = 499
|
|
||||||
const val HTTP_TIMEOUT = 408
|
|
||||||
const val HTTP_TOO_MANY = 429
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-42
@@ -1,10 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
@@ -18,15 +15,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
|||||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -59,26 +50,10 @@ class SyncController @Inject constructor(
|
|||||||
private val trackDao: CachedTrackDao,
|
private val trackDao: CachedTrackDao,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: SyncApi = retrofit.create()
|
private val api: SyncApi = retrofit.create()
|
||||||
|
|
||||||
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
|
||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
|
||||||
private val mutex = Mutex()
|
|
||||||
|
|
||||||
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Human copy for the most recent sync failure; null after any clean
|
|
||||||
* pass. Lets the Library screen distinguish "empty because the first
|
|
||||||
* sync failed" (show error + retry) from "genuinely empty library"
|
|
||||||
* (show welcome copy) — a failed sync over a populated cache stays
|
|
||||||
* silent, since the cached content is still the better surface.
|
|
||||||
*/
|
|
||||||
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -86,27 +61,11 @@ class SyncController @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { syncSafe() }
|
.collect { syncSafe() }
|
||||||
}
|
}
|
||||||
// Re-sync when the server becomes reachable again mid-session, so a
|
|
||||||
// delta missed while offline lands without waiting for a cold start.
|
|
||||||
scope.launch {
|
|
||||||
networkStatus.state
|
|
||||||
.map { it == ServerHealth.Healthy }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.filter { it }
|
|
||||||
.collect { syncSafe() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
|
||||||
try {
|
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
.onSuccess { lastSyncErrorInternal.value = null }
|
|
||||||
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
|
||||||
} finally {
|
|
||||||
mutex.unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sync() {
|
private suspend fun sync() {
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ import javax.inject.Singleton
|
|||||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||||
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
|
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which
|
||||||
* has its own failure hysteresis), not this coarse device-link signal.
|
* has its own failure hysteresis), not this coarse device-link signal.
|
||||||
*
|
*
|
||||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||||
|
|||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
|
||||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
|
||||||
import com.fabledsword.minstrel.update.api.HealthzResponse
|
|
||||||
import com.fabledsword.minstrel.update.data.VersionResult
|
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
|
|
||||||
private const val POLL_DEGRADED_MS = 20_000L
|
|
||||||
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* THE single authority on network/server reachability. Absorbs the former
|
|
||||||
* VersionCheckController (the /healthz poll + version parsing) and
|
|
||||||
* ServerHealthController (the tri-state derive) into one signal-driven unit so
|
|
||||||
* the app has one place that answers "can we reach Minstrel?" — not three
|
|
||||||
* half-systems reporting differently.
|
|
||||||
*
|
|
||||||
* Inputs (all funnel through a single-consumer intent channel for thread
|
|
||||||
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
|
||||||
* and OkHttp threads):
|
|
||||||
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
|
||||||
* immediate probe so recovery is near-instant (fixes the sticky banner).
|
|
||||||
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
|
||||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
|
||||||
* source, and the playback-error reporter.
|
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
|
||||||
*
|
|
||||||
* Version compatibility is a byproduct of the same /healthz response.
|
|
||||||
*
|
|
||||||
* Constructed at launch via the construct-the-singleton trick in
|
|
||||||
* [com.fabledsword.minstrel.MinstrelApplication].
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class NetworkStatusController @Inject constructor(
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
|
||||||
connectivity: ConnectivityObserver,
|
|
||||||
private val authStore: AuthStore,
|
|
||||||
retrofit: Retrofit,
|
|
||||||
) {
|
|
||||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
|
||||||
private val machine = ReachabilityMachine()
|
|
||||||
private val lastProbeAtMs = AtomicLong(0)
|
|
||||||
|
|
||||||
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
|
|
||||||
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
|
|
||||||
|
|
||||||
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
|
|
||||||
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
|
|
||||||
|
|
||||||
private sealed interface Intent {
|
|
||||||
data class Link(val up: Boolean) : Intent
|
|
||||||
data class Probe(val resp: HealthzResponse?) : Intent
|
|
||||||
object OpSuccess : Intent
|
|
||||||
object OpFailure : Intent
|
|
||||||
}
|
|
||||||
|
|
||||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch { reduceLoop() }
|
|
||||||
scope.launch {
|
|
||||||
connectivity.online.collect { up ->
|
|
||||||
intents.trySend(Intent.Link(up))
|
|
||||||
if (up) probeOnce()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch { pollLoop() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
|
|
||||||
fun reportSuccess() {
|
|
||||||
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
|
|
||||||
fun reportFailure() {
|
|
||||||
if (stateInternal.value == ServerHealth.Offline) return
|
|
||||||
intents.trySend(Intent.OpFailure)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-shot recheck for pull-to-refresh and the banner. */
|
|
||||||
fun recheck() {
|
|
||||||
scope.launch { probeOnce(force = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun reduceLoop() {
|
|
||||||
for (intent in intents) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
when (intent) {
|
|
||||||
is Intent.Link -> machine.onLinkChange(intent.up)
|
|
||||||
is Intent.Probe -> applyProbe(intent.resp, now)
|
|
||||||
Intent.OpSuccess -> machine.onSuccess()
|
|
||||||
Intent.OpFailure -> {
|
|
||||||
machine.onOpFailure(now)
|
|
||||||
// Arbitrate off the reducer thread: awaiting a stalled
|
|
||||||
// /healthz here would block a concurrent self-proving
|
|
||||||
// success from snapping us straight back to Healthy.
|
|
||||||
scope.launch { probeOnce() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit(machine.health())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyProbe(resp: HealthzResponse?, now: Long) {
|
|
||||||
if (resp == null) {
|
|
||||||
machine.onProbeFailure(now)
|
|
||||||
} else {
|
|
||||||
machine.onSuccess()
|
|
||||||
versionInternal.value = versionResultFor(resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emit(next: ServerHealth) {
|
|
||||||
if (stateInternal.value != next) {
|
|
||||||
Timber.w("NetworkStatus -> %s", next)
|
|
||||||
stateInternal.value = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun pollLoop() {
|
|
||||||
probeOnce()
|
|
||||||
while (true) {
|
|
||||||
val interval =
|
|
||||||
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
|
|
||||||
else POLL_DEGRADED_MS
|
|
||||||
delay(interval)
|
|
||||||
probeOnce()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun probeOnce(force: Boolean = false) {
|
|
||||||
// Startup guard: don't poll the localhost placeholder before AuthStore
|
|
||||||
// hydrates the real base URL — that false failure used to flash the banner.
|
|
||||||
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
|
||||||
lastProbeAtMs.set(now)
|
|
||||||
val resp = runCatching { api.check() }.getOrNull()
|
|
||||||
intents.trySend(Intent.Probe(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun versionResultFor(resp: HealthzResponse): VersionResult {
|
|
||||||
val min = resp.minClientVersion
|
|
||||||
return when {
|
|
||||||
min.isEmpty() -> VersionResult.SKIPPED
|
|
||||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
|
||||||
else -> VersionResult.OK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
|
|
||||||
* composables (TrackRow gating, write-affordance disabling) branch on health
|
|
||||||
* without re-injecting the controller. Defaults to Healthy so previews/tests
|
|
||||||
* don't crash.
|
|
||||||
*/
|
|
||||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
|
||||||
-88
@@ -1,88 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
|
||||||
|
|
||||||
internal const val ESCALATE_AFTER_MS = 120_000L
|
|
||||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
|
||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
|
||||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
|
||||||
* testable. [NetworkStatusController] wires real time + signals around it.
|
|
||||||
*
|
|
||||||
* Reachability (independent of the device link):
|
|
||||||
* - Reachable last evidence says the server answered.
|
|
||||||
* - Unstable a probe failed; arbitration/escalation pending.
|
|
||||||
* - Unreachable corroborated or sustained failure.
|
|
||||||
*
|
|
||||||
* [health] folds the device link over that: no link → Offline; otherwise the
|
|
||||||
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
|
||||||
*
|
|
||||||
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
|
||||||
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
|
||||||
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
|
||||||
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
|
||||||
* sustained-time backstop.
|
|
||||||
*/
|
|
||||||
class ReachabilityMachine {
|
|
||||||
|
|
||||||
private enum class Reachability { Reachable, Unstable, Unreachable }
|
|
||||||
|
|
||||||
private var linkUp = true
|
|
||||||
private var reachability = Reachability.Reachable
|
|
||||||
private var failureStreakStartMs: Long? = null
|
|
||||||
private val recentOpFailures = ArrayDeque<Long>()
|
|
||||||
|
|
||||||
fun onLinkChange(up: Boolean) {
|
|
||||||
linkUp = up
|
|
||||||
// Link transitions don't reset reachability — a restored link keeps the
|
|
||||||
// last-known server reachability until a fresh probe/op result arrives.
|
|
||||||
if (!up) recentOpFailures.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
|
||||||
fun onSuccess() {
|
|
||||||
reachability = Reachability.Reachable
|
|
||||||
failureStreakStartMs = null
|
|
||||||
recentOpFailures.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
|
||||||
fun onOpFailure(nowMs: Long) {
|
|
||||||
pruneOpFailures(nowMs)
|
|
||||||
recentOpFailures.addLast(nowMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
|
||||||
fun onProbeFailure(nowMs: Long) {
|
|
||||||
pruneOpFailures(nowMs)
|
|
||||||
if (reachability == Reachability.Reachable) {
|
|
||||||
reachability = Reachability.Unstable
|
|
||||||
failureStreakStartMs = nowMs
|
|
||||||
}
|
|
||||||
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
|
||||||
reachability = Reachability.Unreachable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun health(): ServerHealth = when {
|
|
||||||
!linkUp -> ServerHealth.Offline
|
|
||||||
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
|
||||||
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
|
||||||
else -> ServerHealth.ServerDown
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldEscalate(nowMs: Long): Boolean {
|
|
||||||
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
|
||||||
val sustained =
|
|
||||||
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
|
||||||
return corroborated || sustained
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pruneOpFailures(nowMs: Long) {
|
|
||||||
while (recentOpFailures.isNotEmpty() &&
|
|
||||||
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
|
||||||
) {
|
|
||||||
recentOpFailures.removeFirst()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
|
|
||||||
import dagger.Lazy
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private const val HEALTHZ_PATH = "/healthz"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
|
|
||||||
* self-proving proof the server is reachable → reportSuccess(); a transport
|
|
||||||
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
|
|
||||||
* arbitration.
|
|
||||||
*
|
|
||||||
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
|
|
||||||
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
|
|
||||||
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
|
|
||||||
* loading must NOT be read as "our server is reachable" — only sentinel-host
|
|
||||||
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
|
|
||||||
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
|
|
||||||
*
|
|
||||||
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
|
|
||||||
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
|
|
||||||
* interceptor. By the time a request flows through, the controller singleton is
|
|
||||||
* already constructed (construct-the-singleton trick in MinstrelApplication).
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ReachabilityReportingInterceptor @Inject constructor(
|
|
||||||
private val networkStatus: Lazy<NetworkStatusController>,
|
|
||||||
) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
val isMinstrel = request.url.host == PLACEHOLDER_HOST
|
|
||||||
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
|
|
||||||
if (!isMinstrel || isHealthz) return chain.proceed(request)
|
|
||||||
return try {
|
|
||||||
val response = chain.proceed(request)
|
|
||||||
if (response.isSuccessful) networkStatus.get().reportSuccess()
|
|
||||||
response
|
|
||||||
} catch (e: IOException) {
|
|
||||||
networkStatus.get().reportFailure()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
|
||||||
* after the collector subscribed — the StateFlow's replayed current value
|
|
||||||
* is dropped, so only genuine down→up transitions fire (a screen that
|
|
||||||
* subscribes while already Healthy doesn't double-load).
|
|
||||||
*
|
|
||||||
* This is the screen-level half of the recovery idiom the app-lifetime
|
|
||||||
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
|
||||||
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
|
||||||
* its load, so a surface that failed while the server was unreachable
|
|
||||||
* heals itself the moment connectivity returns instead of sitting in the
|
|
||||||
* failed state until a manual pull-to-refresh.
|
|
||||||
*/
|
|
||||||
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
|
||||||
.map { it == ServerHealth.Healthy }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.drop(1)
|
|
||||||
.filter { it }
|
|
||||||
.map { }
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single reachability signal every consumer branches on.
|
|
||||||
*
|
|
||||||
* - [Healthy] link up, /healthz ok — normal network behavior.
|
|
||||||
* - [Unstable] link up, a recent failure with arbitration pending —
|
|
||||||
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
|
||||||
* anti-flicker intent of commit 5c0db429 while still warning
|
|
||||||
* the user that something is flaky.
|
|
||||||
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
|
||||||
* gate to cache-only.
|
|
||||||
* - [Offline] no device link at all — gate to cache-only.
|
|
||||||
*/
|
|
||||||
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tri-state server-reachability signal that downstream consumers can branch
|
||||||
|
* on to decide whether to hit the network, gate writes, or fall back to
|
||||||
|
* cache-only behavior.
|
||||||
|
*
|
||||||
|
* Composed from two existing signals -- this controller doesn't poll its own
|
||||||
|
* endpoint:
|
||||||
|
*
|
||||||
|
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
|
||||||
|
* answers only "is there an active INTERNET-capable network link" (NOT
|
||||||
|
* VALIDATED -- a WAN-validation flicker must not read as offline when
|
||||||
|
* the LAN server is reachable).
|
||||||
|
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
|
||||||
|
* succeed. This is the authority on whether *Minstrel* is reachable;
|
||||||
|
* it has its own failure hysteresis. Distinguishes "device has a link
|
||||||
|
* but our server is down" from "no network at all."
|
||||||
|
*
|
||||||
|
* `version too old` is intentionally *not* folded in here -- it's a separate
|
||||||
|
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
|
||||||
|
* real cause.
|
||||||
|
*/
|
||||||
|
enum class ServerHealth { Healthy, Offline, ServerDown }
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerHealthController @Inject constructor(
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
connectivity: ConnectivityObserver,
|
||||||
|
versionCheck: VersionCheckController,
|
||||||
|
) {
|
||||||
|
val state: StateFlow<ServerHealth> = combine(
|
||||||
|
connectivity.online,
|
||||||
|
versionCheck.reachable,
|
||||||
|
) { online, serverReachable ->
|
||||||
|
when {
|
||||||
|
!online -> ServerHealth.Offline
|
||||||
|
!serverReachable -> ServerHealth.ServerDown
|
||||||
|
else -> ServerHealth.Healthy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Transition log -- the signal had no instrumentation, which is why a
|
||||||
|
// false-offline (WAN flicker flipping playback to "Source error") was
|
||||||
|
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { Timber.w("ServerHealth -> %s", it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = scope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = ServerHealth.Healthy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive [ServerHealth] snapshot provided once at the app root from the
|
||||||
|
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
|
||||||
|
* affordance disabling) branch on health without each ViewModel re-
|
||||||
|
* injecting the controller. Defaults to Healthy so unwrapped previews
|
||||||
|
* and tests don't crash.
|
||||||
|
*/
|
||||||
|
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||||
+33
-71
@@ -14,121 +14,83 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.composables.icons.lucide.CloudOff
|
import com.composables.icons.lucide.CloudOff
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
* composable. Keeps the banner pure-presentation.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ConnectivityBannerViewModel @Inject constructor(
|
class ConnectivityBannerViewModel @Inject constructor(
|
||||||
networkStatus: NetworkStatusController,
|
health: ServerHealthController,
|
||||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = ServerHealth.Healthy,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
* Banner shown at the top of the shell when the user can't reach the server.
|
||||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||||
* window, and a brief "Back online" confirmation when health recovers so
|
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
* health returns to [ServerHealth.Healthy].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionErrorBanner(
|
fun ConnectionErrorBanner(
|
||||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||||
var showBackOnline by remember { mutableStateOf(false) }
|
|
||||||
var wasDown by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(health) {
|
|
||||||
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
|
|
||||||
if (health == ServerHealth.Healthy && wasDown) {
|
|
||||||
// try/finally so a mid-delay cancellation (health flips again) can't
|
|
||||||
// orphan the flag and leave "Back online" stuck on screen.
|
|
||||||
try {
|
|
||||||
showBackOnline = true
|
|
||||||
delay(BACK_ONLINE_FLASH_MS)
|
|
||||||
} finally {
|
|
||||||
showBackOnline = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wasDown = down
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
visible = health != ServerHealth.Healthy,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
BannerContent(health = health, backOnline = showBackOnline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val background: Color
|
|
||||||
val foreground: Color
|
|
||||||
when {
|
|
||||||
backOnline -> {
|
|
||||||
background = scheme.secondaryContainer
|
|
||||||
foreground = scheme.onSecondaryContainer
|
|
||||||
}
|
|
||||||
health == ServerHealth.Unstable -> {
|
|
||||||
background = scheme.surfaceVariant
|
|
||||||
foreground = scheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
background = scheme.errorContainer
|
|
||||||
foreground = scheme.onErrorContainer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(background)
|
.background(MaterialTheme.colorScheme.errorContainer)
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
Icon(
|
||||||
|
imageVector = Lucide.CloudOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = bannerText(health, backOnline),
|
text = when (health) {
|
||||||
|
ServerHealth.Offline ->
|
||||||
|
"No connection — check Wi-Fi or mobile data."
|
||||||
|
ServerHealth.ServerDown ->
|
||||||
|
"Server unreachable — your cached content is still available."
|
||||||
|
ServerHealth.Healthy -> ""
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = foreground,
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
|
||||||
backOnline -> "Back online."
|
|
||||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
|
||||||
health == ServerHealth.ServerDown ->
|
|
||||||
"Server unreachable — your cached content is still available."
|
|
||||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
|
||||||
else -> ""
|
|
||||||
}
|
}
|
||||||
|
|||||||
-328
@@ -1,328 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.diagnostics
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.PowerManager
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
|
||||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
|
||||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
|
||||||
import com.fabledsword.minstrel.player.output.OutputPickerController
|
|
||||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On-device diagnostics collector (M9). Active only while the account's
|
|
||||||
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
|
||||||
* AND the user hasn't opted out locally. When active it taps existing
|
|
||||||
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
|
||||||
* timeseries of events into the [DiagnosticEventDao] ring buffer; the
|
|
||||||
* [DiagnosticsUploader] drains that buffer to the server.
|
|
||||||
*
|
|
||||||
* The aim is to explain two failure classes after the fact: roaming /
|
|
||||||
* poor-data recovery (connectivity + server-health transitions) and
|
|
||||||
* locked-phone UPnP desync (power/Doze transitions correlated with the
|
|
||||||
* Sonos-vs-local position deltas captured in the heartbeat).
|
|
||||||
*/
|
|
||||||
@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive
|
|
||||||
@Singleton
|
|
||||||
class DiagnosticsReporter @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
|
||||||
private val dao: DiagnosticEventDao,
|
|
||||||
private val authController: AuthController,
|
|
||||||
private val authStore: AuthStore,
|
|
||||||
private val connectivity: ConnectivityObserver,
|
|
||||||
private val networkStatus: NetworkStatusController,
|
|
||||||
private val playerController: PlayerController,
|
|
||||||
private val outputPicker: OutputPickerController,
|
|
||||||
private val remoteState: RemotePlayerState,
|
|
||||||
private val json: Json,
|
|
||||||
) {
|
|
||||||
@Volatile private var enabled = false
|
|
||||||
private var collectorJob: Job? = null
|
|
||||||
private var powerReceiver: BroadcastReceiver? = null
|
|
||||||
private var lifecycleObserver: DefaultLifecycleObserver? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Gate = account flag (currentUser.debugModeEnabled) AND not the
|
|
||||||
// per-device local opt-out.
|
|
||||||
scope.launch {
|
|
||||||
combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut ->
|
|
||||||
(user?.debugModeEnabled == true) && !optOut
|
|
||||||
}.distinctUntilChanged().collect { setEnabled(it) }
|
|
||||||
}
|
|
||||||
// Periodically re-pull /me so a remote admin flip propagates
|
|
||||||
// without a re-login or app restart.
|
|
||||||
scope.launch {
|
|
||||||
while (true) {
|
|
||||||
delay(GATE_REFRESH_MS)
|
|
||||||
authController.refreshProfile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setEnabled(value: Boolean) {
|
|
||||||
if (value == enabled) return
|
|
||||||
enabled = value
|
|
||||||
if (value) start() else stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun start() {
|
|
||||||
registerPowerReceiver()
|
|
||||||
registerLifecycleObserver()
|
|
||||||
collectorJob = scope.launch {
|
|
||||||
record("lifecycle", buildJsonObject { put("event", "reporting_started") })
|
|
||||||
launch { collectConnectivity() }
|
|
||||||
launch { collectServerHealth() }
|
|
||||||
launch { collectUpnpDrops() }
|
|
||||||
launch { collectPlayerState() }
|
|
||||||
launch { collectTrackChanges() }
|
|
||||||
launch { collectRoutes() }
|
|
||||||
launch { heartbeatLoop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stop() {
|
|
||||||
collectorJob?.cancel()
|
|
||||||
collectorJob = null
|
|
||||||
unregisterPowerReceiver()
|
|
||||||
unregisterLifecycleObserver()
|
|
||||||
// Off means off: drop any unsent buffer (honors the user OFF
|
|
||||||
// intent and avoids leaking data captured before disable).
|
|
||||||
scope.launch { runCatching { dao.clear() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun collectConnectivity() {
|
|
||||||
connectivity.online.distinctUntilChanged().collect { online ->
|
|
||||||
record("connectivity", buildJsonObject {
|
|
||||||
put("event", "online_changed")
|
|
||||||
put("online", online)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun collectServerHealth() {
|
|
||||||
// state is a StateFlow — already conflated/distinct, so no
|
|
||||||
// distinctUntilChanged (it's a deprecation warning = build error).
|
|
||||||
networkStatus.state.collect { s ->
|
|
||||||
record("connectivity", buildJsonObject {
|
|
||||||
put("event", "server_health")
|
|
||||||
put("state", s.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun collectUpnpDrops() {
|
|
||||||
playerController.dropEvents.collect { msg ->
|
|
||||||
record("upnp_sync", buildJsonObject {
|
|
||||||
put("event", "drop")
|
|
||||||
put("message", msg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun collectPlayerState() {
|
|
||||||
// 'playback', not 'upnp_sync' — player state applies to every output
|
|
||||||
// route (phone speaker, Bluetooth, UPnP), not just casting.
|
|
||||||
playerController.uiState
|
|
||||||
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (source, upnpLoading, err) ->
|
|
||||||
record("playback", buildJsonObject {
|
|
||||||
put("event", "player_state")
|
|
||||||
put("source", source ?: "")
|
|
||||||
put("upnp_loading", upnpLoading)
|
|
||||||
if (err != null) put("error", err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit a snapshot at each track/queue-index change — captures a
|
|
||||||
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
|
|
||||||
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
|
|
||||||
// very rapid skip burst may coalesce intermediate indices (we still get
|
|
||||||
// the boundaries + the local-vs-Sonos snapshot).
|
|
||||||
private suspend fun collectTrackChanges() {
|
|
||||||
playerController.uiState
|
|
||||||
.map { it.queueIndex to it.currentTrack?.id }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (index, _) ->
|
|
||||||
val ui = playerController.uiState.value
|
|
||||||
val casting = outputPicker.routesState.value.current.protocol !=
|
|
||||||
OutputRoute.Protocol.SYSTEM
|
|
||||||
record("playback", buildJsonObject {
|
|
||||||
put("event", "track_change")
|
|
||||||
put("local_index", index)
|
|
||||||
// Track IDENTITY, not just index — indices wobble across
|
|
||||||
// re-casts, so the id + Sonos URI make a desync unambiguous.
|
|
||||||
put("local_track_id", ui.currentTrack?.id ?: "")
|
|
||||||
put("local_pos_ms", ui.positionMs)
|
|
||||||
putSonos(this, casting)
|
|
||||||
put("upnp_loading", ui.isUpnpLoading)
|
|
||||||
put("server_health", networkStatus.state.value.name)
|
|
||||||
put("route", outputPicker.routesState.value.current.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun collectRoutes() {
|
|
||||||
// 'playback' — route changes happen for all outputs. This only ever
|
|
||||||
// logs the ACTIVE route (routesState.current), so no "connected" flag.
|
|
||||||
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
|
|
||||||
record("playback", buildJsonObject {
|
|
||||||
put("event", "route")
|
|
||||||
put("id", r.id)
|
|
||||||
put("name", r.name)
|
|
||||||
put("kind", r.kind.name)
|
|
||||||
put("protocol", r.protocol.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun heartbeatLoop() {
|
|
||||||
while (true) {
|
|
||||||
delay(HEARTBEAT_MS)
|
|
||||||
val ui = playerController.uiState.value
|
|
||||||
val route = outputPicker.routesState.value.current
|
|
||||||
val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM
|
|
||||||
// Idle = nothing worth sampling. Skip to keep the buffer lean.
|
|
||||||
if (!ui.isPlaying && !routeActive) continue
|
|
||||||
record("heartbeat", buildJsonObject {
|
|
||||||
put("server_health", networkStatus.state.value.name)
|
|
||||||
put("is_playing", ui.isPlaying)
|
|
||||||
put("source", ui.currentSource ?: "")
|
|
||||||
put("local_index", ui.queueIndex)
|
|
||||||
put("local_track_id", ui.currentTrack?.id ?: "")
|
|
||||||
put("local_pos_ms", ui.positionMs)
|
|
||||||
put("route", route.name)
|
|
||||||
put("route_protocol", route.protocol.name)
|
|
||||||
putSonos(this, routeActive)
|
|
||||||
addPowerFields(this)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- power + lifecycle taps -------------------------------------------
|
|
||||||
|
|
||||||
private fun registerPowerReceiver() {
|
|
||||||
if (powerReceiver != null) return
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(c: Context?, intent: Intent?) {
|
|
||||||
val action = intent?.action ?: return
|
|
||||||
if (!enabled) return
|
|
||||||
scope.launch { record("power", powerEvent(action)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction(Intent.ACTION_SCREEN_ON)
|
|
||||||
addAction(Intent.ACTION_SCREEN_OFF)
|
|
||||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
|
||||||
}
|
|
||||||
// System-protected broadcasts, but pass NOT_EXPORTED explicitly so
|
|
||||||
// the API 34+ registerReceiver flag requirement is satisfied.
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
powerReceiver = receiver
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterPowerReceiver() {
|
|
||||||
powerReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
|
||||||
powerReceiver = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerLifecycleObserver() {
|
|
||||||
if (lifecycleObserver != null) return
|
|
||||||
val obs = object : DefaultLifecycleObserver {
|
|
||||||
override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground")
|
|
||||||
override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background")
|
|
||||||
}
|
|
||||||
lifecycleObserver = obs
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterLifecycleObserver() {
|
|
||||||
val obs = lifecycleObserver ?: return
|
|
||||||
lifecycleObserver = null
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(obs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logLifecycle(event: String) {
|
|
||||||
if (!enabled) return
|
|
||||||
scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun powerEvent(action: String): JsonObject = buildJsonObject {
|
|
||||||
put("event", action.substringAfterLast('.'))
|
|
||||||
addPowerFields(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
|
|
||||||
// remote route is active; zeroed otherwise so a stale RemotePlayerState
|
|
||||||
// from a just-ended cast can't masquerade as live Sonos data (the
|
|
||||||
// cast→phone handoff artifact). currentTrackUri is the desync ground
|
|
||||||
// truth — it carries the track the speaker is actually streaming.
|
|
||||||
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
|
|
||||||
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
|
|
||||||
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
|
|
||||||
builder.put("sonos_playing", casting && remoteState.isPlaying)
|
|
||||||
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
|
|
||||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
builder.put("doze", pm.isDeviceIdleMode)
|
|
||||||
builder.put("screen_on", pm.isInteractive)
|
|
||||||
builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun record(kind: String, payload: JsonObject) {
|
|
||||||
runCatching {
|
|
||||||
val id = dao.insert(
|
|
||||||
DiagnosticEventEntity(
|
|
||||||
kind = kind,
|
|
||||||
occurredAtMillis = System.currentTimeMillis(),
|
|
||||||
payloadJson = json.encodeToString(JsonObject.serializer(), payload),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val HEARTBEAT_MS = 45_000L
|
|
||||||
const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min
|
|
||||||
const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped
|
|
||||||
const val TRIM_INTERVAL = 50L // trim every N inserts, not every one
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.diagnostics
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticEventWire
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticsApi
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.DiagnosticsReportRequest
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.create
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
|
||||||
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
|
||||||
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
|
||||||
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
|
||||||
* upload the moment the link comes back.
|
|
||||||
*
|
|
||||||
* Deliberately NOT the MutationQueue: diagnostics are high-volume,
|
|
||||||
* best-effort telemetry. A failed upload just retries on the next tick;
|
|
||||||
* the server no-ops (204) when the account's debug flag is off, which the
|
|
||||||
* uploader treats as success so the buffer drains either way.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class DiagnosticsUploader @Inject constructor(
|
|
||||||
private val dao: DiagnosticEventDao,
|
|
||||||
private val authStore: AuthStore,
|
|
||||||
private val json: Json,
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
retrofit: Retrofit,
|
|
||||||
) {
|
|
||||||
private val api: DiagnosticsApi = retrofit.create()
|
|
||||||
private val mutex = Mutex()
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
while (true) {
|
|
||||||
delay(UPLOAD_INTERVAL_MS)
|
|
||||||
drainSafe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
authStore.sessionCookie.filterNotNull().distinctUntilChanged().collect { drainSafe() }
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
networkStatus.state
|
|
||||||
.map { it == ServerHealth.Healthy }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.filter { it }
|
|
||||||
.collect { drainSafe() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coalesces concurrent triggers via tryLock — a drain in flight wins. */
|
|
||||||
private suspend fun drainSafe() {
|
|
||||||
val clientId = authStore.clientId.value
|
|
||||||
// Signed out / no client id yet → nothing to do. Single guard keeps
|
|
||||||
// the return count within the detekt gate.
|
|
||||||
if (authStore.sessionCookie.value.isNullOrEmpty() || clientId == null) return
|
|
||||||
if (!mutex.tryLock()) return
|
|
||||||
try {
|
|
||||||
drain(clientId)
|
|
||||||
} finally {
|
|
||||||
mutex.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun drain(clientId: String) {
|
|
||||||
var more = true
|
|
||||||
while (more) {
|
|
||||||
val batch = runCatching { dao.takeBatch(BATCH_SIZE) }.getOrNull().orEmpty()
|
|
||||||
val sent = batch.isNotEmpty() && runCatching { upload(clientId, batch) }.isSuccess
|
|
||||||
if (sent) runCatching { dao.deleteByIds(batch.map { it.id }) }
|
|
||||||
// Keep going only while a full batch sent cleanly; otherwise stop
|
|
||||||
// (empty buffer, or a failure to retry on the next trigger).
|
|
||||||
more = sent && batch.size >= BATCH_SIZE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun upload(clientId: String, batch: List<DiagnosticEventEntity>) {
|
|
||||||
api.report(
|
|
||||||
DiagnosticsReportRequest(
|
|
||||||
clientId = clientId,
|
|
||||||
appVersion = BuildConfig.VERSION_NAME,
|
|
||||||
osVersion = "Android ${Build.VERSION.RELEASE} (${Build.MODEL})",
|
|
||||||
events = batch.map { row ->
|
|
||||||
DiagnosticEventWire(
|
|
||||||
kind = row.kind,
|
|
||||||
occurredAt = row.occurredAtMillis,
|
|
||||||
payload = json.parseToJsonElement(row.payloadJson),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val UPLOAD_INTERVAL_MS = 60_000L
|
|
||||||
const val BATCH_SIZE = 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,6 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -110,7 +109,6 @@ private fun DiscoverBody(
|
|||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRetry = { viewModel.loadSuggestions() },
|
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
is ResultsState.Loaded -> ResultsList(
|
||||||
@@ -123,11 +121,7 @@ private fun DiscoverBody(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is ResultsState.Error -> ErrorRetry(
|
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
||||||
title = "Search failed",
|
|
||||||
message = r.message,
|
|
||||||
onRetry = viewModel::runSearch,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,15 +177,10 @@ private fun SuggestionsPane(
|
|||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
onRetry: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
SuggestionState.Loading -> LoadingCentered()
|
||||||
is SuggestionState.Error -> ErrorRetry(
|
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
||||||
title = "Couldn't load suggestions",
|
|
||||||
message = state.message,
|
|
||||||
onRetry = onRetry,
|
|
||||||
)
|
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
@@ -48,7 +46,6 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -56,15 +53,6 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
|
||||||
// failed while the server was unreachable once health returns.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
val s = internal.value
|
|
||||||
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
|
||||||
if (s.results is ResultsState.Error) runSearch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
@@ -28,7 +26,6 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -59,7 +56,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -86,11 +82,6 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
|
||||||
// health returns, so a load that failed offline heals unprompted.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refresh() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -125,10 +116,9 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ import javax.inject.Singleton
|
|||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
// Per-section observe accessors (one per Home row) inflate the function
|
|
||||||
// count past detekt's 11 default — accessor density, not complexity.
|
|
||||||
@Suppress("TooManyFunctions")
|
|
||||||
class HomeRepository @Inject constructor(
|
class HomeRepository @Inject constructor(
|
||||||
private val homeIndexDao: CachedHomeIndexDao,
|
private val homeIndexDao: CachedHomeIndexDao,
|
||||||
private val metadataProvider: MetadataProvider,
|
private val metadataProvider: MetadataProvider,
|
||||||
@@ -79,12 +76,6 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||||
|
|
||||||
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
|
||||||
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
|
||||||
|
|
||||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
|
||||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* and pre-warms the top artists. The section Flows re-fire on the
|
||||||
@@ -97,11 +88,7 @@ class HomeRepository @Inject constructor(
|
|||||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
|
||||||
prewarmer.warm(
|
|
||||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||||
@@ -161,7 +148,5 @@ class HomeRepository @Inject constructor(
|
|||||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,11 @@
|
|||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -35,7 +27,6 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -63,8 +54,6 @@ import com.composables.icons.lucide.History
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -87,7 +76,6 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -97,7 +85,6 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -107,7 +94,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -120,16 +106,6 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
// "Updating your mixes…" veil (automatic refresh). Held through the pull
|
|
||||||
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
|
||||||
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
|
||||||
private const val VEIL_SETTLE_MS = 500L
|
|
||||||
private const val VEIL_WIPE_MS = 280
|
|
||||||
private const val VEIL_ALPHA = 0.96f
|
|
||||||
private const val VEIL_SPINNER_DP = 22
|
|
||||||
private const val VEIL_SPINNER_STROKE_DP = 2
|
|
||||||
private const val VEIL_LABEL_GAP_DP = 12
|
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -139,8 +115,6 @@ data class HomeSections(
|
|||||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
|
||||||
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
|
||||||
) {
|
) {
|
||||||
val isAllEmpty: Boolean
|
val isAllEmpty: Boolean
|
||||||
get() = playlists.isEmpty() &&
|
get() = playlists.isEmpty() &&
|
||||||
@@ -148,9 +122,7 @@ data class HomeSections(
|
|||||||
rediscoverAlbums.isEmpty() &&
|
rediscoverAlbums.isEmpty() &&
|
||||||
rediscoverArtists.isEmpty() &&
|
rediscoverArtists.isEmpty() &&
|
||||||
mostPlayedTracks.isEmpty() &&
|
mostPlayedTracks.isEmpty() &&
|
||||||
lastPlayedArtists.isEmpty() &&
|
lastPlayedArtists.isEmpty()
|
||||||
youMightLikeAlbums.isEmpty() &&
|
|
||||||
youMightLikeArtists.isEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
@@ -162,8 +134,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||||
@@ -171,13 +142,9 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** System-playlist build status for the Home placeholder cards. */
|
/** System-playlist build status for the Home placeholder cards. */
|
||||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||||
|
|
||||||
/**
|
/** True when the device has no usable internet — gates the offline-pool cards. */
|
||||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
val offline: StateFlow<Boolean> = connectivity.online
|
||||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
.map { !it }
|
||||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
|
||||||
*/
|
|
||||||
val offline: StateFlow<Boolean> = networkStatus.state
|
|
||||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
@@ -189,45 +156,8 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from offline-pool taps. */
|
/** Transient snackbar messages from offline-pool taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy for the most recent /home/index refresh failure; null once a
|
|
||||||
* refresh succeeds. Folded into [uiState] so an empty cache + failed
|
|
||||||
* refresh renders as Error (with retry) instead of masquerading as
|
|
||||||
* the "Welcome to Minstrel" empty state.
|
|
||||||
*/
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
private val updatingInternal = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True while an automatic background refresh (the 03:00 daily rebuild
|
|
||||||
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
|
||||||
* your mixes…" veil so the section churn — delete-then-insert in
|
|
||||||
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
|
||||||
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
|
||||||
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
|
||||||
*/
|
|
||||||
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
|
||||||
// load while the server was unreachable re-pulls itself the moment
|
|
||||||
// health returns — same idiom as SyncController, one layer up.
|
|
||||||
// Veiled: content is already on screen and would otherwise churn.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
|
||||||
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
|
|
||||||
// rebuild churn hides behind "Updating your mixes…".
|
|
||||||
viewModelScope.launch {
|
|
||||||
eventsStream.events
|
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
|
||||||
.collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,14 +269,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
* actual completion before hiding the indicator.
|
* actual completion before hiding the indicator.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
refreshError.value = null
|
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
||||||
val home = launch {
|
|
||||||
// /home/index is the load-bearing pull: its failure drives the
|
|
||||||
// empty-cache Error state. A failure over a populated cache
|
|
||||||
// stays silent — cached sections beat a full-screen error.
|
|
||||||
runCatching { homeRepository.refreshIndex() }
|
|
||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
|
||||||
}
|
|
||||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
@@ -357,38 +280,17 @@ class HomeViewModel @Inject constructor(
|
|||||||
status.join()
|
status.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatic background refresh with the "Updating your mixes…" veil
|
|
||||||
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
|
||||||
* paths where Home is already on screen. Holds the veil through the
|
|
||||||
* pull plus a short settle so per-tile hydration lands behind it, then
|
|
||||||
* lets it wipe off. Overlapping automatic refreshes are rare enough
|
|
||||||
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
|
|
||||||
*/
|
|
||||||
private fun refreshBehindVeil() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
updatingInternal.value = true
|
|
||||||
try {
|
|
||||||
refresh().join()
|
|
||||||
delay(VEIL_SETTLE_MS)
|
|
||||||
} finally {
|
|
||||||
updatingInternal.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<HomeSections>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core five section flows merged into the sections struct. Split
|
* Five home-section flows merged into the sections struct without
|
||||||
* out so each combine call stays inside the coroutines built-in arity
|
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||||
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
* Splitting it like this keeps each combine call inside the
|
||||||
* across heterogeneous types would need. [observeHomeSections] then
|
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||||
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||||
* playlists flow.
|
|
||||||
*/
|
*/
|
||||||
private fun observeCoreSections() = combine(
|
private fun observeHomeSections() = combine(
|
||||||
homeRepository.observeRecentlyAddedAlbums(),
|
homeRepository.observeRecentlyAddedAlbums(),
|
||||||
homeRepository.observeRediscoverAlbums(),
|
homeRepository.observeRediscoverAlbums(),
|
||||||
homeRepository.observeRediscoverArtists(),
|
homeRepository.observeRediscoverArtists(),
|
||||||
@@ -404,26 +306,10 @@ class HomeViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeHomeSections() = combine(
|
|
||||||
observeCoreSections(),
|
|
||||||
homeRepository.observeYouMightLikeAlbums(),
|
|
||||||
homeRepository.observeYouMightLikeArtists(),
|
|
||||||
) { core, ymlAlbums, ymlArtists ->
|
|
||||||
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
combine(
|
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||||
observeHomeSections(),
|
|
||||||
playlistsRepository.observeAll(),
|
|
||||||
refreshError,
|
|
||||||
) { sections, playlists, err ->
|
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
when {
|
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
||||||
!merged.isAllEmpty -> UiState.Success(merged)
|
|
||||||
err != null -> UiState.Error(err)
|
|
||||||
else -> UiState.Empty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,41 +338,19 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
// Key Crossfade on the state CLASS, not the instance. Each
|
||||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
// section emission produces a new UiState.Success(data); if
|
||||||
// Automatic-refresh veil: the daily rebuild / reconnect
|
// we keyed on `state` directly, every per-section
|
||||||
// churn hides behind an "Updating your mixes…" wipe. Manual
|
// hydration tick would re-run the 300ms crossfade, and
|
||||||
// pull owns the PullToRefreshBox spinner instead.
|
// first-sign-in (six sections cascading in) reads as
|
||||||
UpdatingVeil(visible = updating)
|
// continuous flicker. Keying on the class restricts the
|
||||||
}
|
// animation to Loading↔Success↔Empty↔Error transitions and
|
||||||
}
|
// lets normal Success→Success recompositions update the
|
||||||
}
|
// LazyColumn without a fade.
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
|
||||||
*
|
|
||||||
* Key the Crossfade on the state CLASS, not the instance. Each section
|
|
||||||
* emission produces a new UiState.Success(data); if we keyed on `state`
|
|
||||||
* directly, every per-section hydration tick would re-run the 300ms
|
|
||||||
* crossfade, and first-sign-in (six sections cascading in) reads as
|
|
||||||
* continuous flicker. Keying on the class restricts the animation to
|
|
||||||
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
|
||||||
* recompositions update the LazyColumn without a fade.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun HomeStateCrossfade(
|
|
||||||
state: UiState<HomeSections>,
|
|
||||||
systemStatus: SystemPlaylistsStatus,
|
|
||||||
offline: Boolean,
|
|
||||||
navController: NavHostController,
|
|
||||||
viewModel: HomeViewModel,
|
|
||||||
) {
|
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
@@ -496,10 +360,9 @@ private fun HomeStateCrossfade(
|
|||||||
"settings, then come back here for system playlists " +
|
"settings, then come back here for system playlists " +
|
||||||
"and recommendations.",
|
"and recommendations.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
@@ -516,45 +379,6 @@ private fun HomeStateCrossfade(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
|
||||||
* holds while an automatic refresh repopulates Home, then wipes off.
|
|
||||||
* Near-opaque so the section churn underneath never shows; swallows taps
|
|
||||||
* while raised so a mid-hydration tile can't be hit.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
|
||||||
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
|
||||||
modifier = Modifier.matchParentSize(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
) {},
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
|
||||||
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
|
||||||
Text(
|
|
||||||
text = "Updating your mixes…",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,12 +447,10 @@ private fun HomeSuccessContent(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover slots
|
// placeholder cards filling the For You / Discover /
|
||||||
// that haven't generated yet. When offline, the cache-backed
|
// 3× Songs-like slots that haven't generated yet. When
|
||||||
// pool cards lead the row. Songs-like now lives in its own
|
// offline, the cache-backed pool cards lead the row.
|
||||||
// dedicated row below (#1491), no longer inside this carousel.
|
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
title = "Playlists",
|
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -636,26 +458,6 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Songs-like is the best-performing surface (#1491) — promoted out
|
|
||||||
// of the Playlists carousel into its own row so it shows a wider
|
|
||||||
// spread of "Songs like {artist}" mixes. Hidden when there's
|
|
||||||
// nothing to show (offline with none cached).
|
|
||||||
songsLikeSection(
|
|
||||||
playlists = sections.playlists,
|
|
||||||
status = systemStatus,
|
|
||||||
offline = offline,
|
|
||||||
onPlaylistClick = onPlaylistClick,
|
|
||||||
onPlayPool = onPlayPool,
|
|
||||||
onPlayPlaylist = onPlayPlaylist,
|
|
||||||
)
|
|
||||||
youMightLikeSection(
|
|
||||||
albums = sections.youMightLikeAlbums,
|
|
||||||
artists = sections.youMightLikeArtists,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onPlayAlbum = onPlayAlbum,
|
|
||||||
onPlayArtist = onPlayArtist,
|
|
||||||
)
|
|
||||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||||
rediscoverSection(
|
rediscoverSection(
|
||||||
albums = sections.rediscoverAlbums,
|
albums = sections.rediscoverAlbums,
|
||||||
@@ -671,35 +473,6 @@ private fun HomeSuccessContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
|
||||||
* rendering with a distinct title; hidden entirely when there's nothing
|
|
||||||
* to show (offline with no cached mixes). Online with none generated yet
|
|
||||||
* still shows a few placeholders so the building / seed-needed state is
|
|
||||||
* visible, matching the pre-promotion carousel behavior.
|
|
||||||
*/
|
|
||||||
private fun LazyListScope.songsLikeSection(
|
|
||||||
playlists: List<PlaylistRef>,
|
|
||||||
status: SystemPlaylistsStatus,
|
|
||||||
offline: Boolean,
|
|
||||||
onPlaylistClick: (String) -> Unit,
|
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
|
||||||
) {
|
|
||||||
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
|
||||||
if (rowItems.isEmpty()) return
|
|
||||||
item {
|
|
||||||
PlaylistsRow(
|
|
||||||
title = "Songs like…",
|
|
||||||
rowItems = rowItems,
|
|
||||||
offline = offline,
|
|
||||||
onPlaylistClick = onPlaylistClick,
|
|
||||||
onPlayPool = onPlayPool,
|
|
||||||
onPlayPlaylist = onPlayPlaylist,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -889,61 +662,6 @@ private fun RediscoverBlock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "You might like" — in-library albums/artists predicted from the user's
|
|
||||||
* taste that they don't actively spin (server section, daily-built, cold-
|
|
||||||
* start gated). Sits directly under the system-playlists row. Same
|
|
||||||
* albums-then-artists block shape as Rediscover; the artist sub-row drops
|
|
||||||
* its title when an album sub-row precedes it so the two read as one section.
|
|
||||||
*/
|
|
||||||
private fun LazyListScope.youMightLikeSection(
|
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
|
||||||
artists: List<HomeTile<ArtistRef>>,
|
|
||||||
onAlbumClick: (String) -> Unit,
|
|
||||||
onArtistClick: (String) -> Unit,
|
|
||||||
onPlayAlbum: suspend (String) -> Unit,
|
|
||||||
onPlayArtist: suspend (String) -> Unit,
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
|
||||||
YouMightLikeBlock(
|
|
||||||
albums = albums,
|
|
||||||
artists = artists,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onPlayAlbum = onPlayAlbum,
|
|
||||||
onPlayArtist = onPlayArtist,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
EmptySection(
|
|
||||||
title = "You might like",
|
|
||||||
body = "We're still learning your taste — keep listening and " +
|
|
||||||
"picks you might like will show up here.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun YouMightLikeBlock(
|
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
|
||||||
artists: List<HomeTile<ArtistRef>>,
|
|
||||||
onAlbumClick: (String) -> Unit,
|
|
||||||
onArtistClick: (String) -> Unit,
|
|
||||||
onPlayAlbum: suspend (String) -> Unit,
|
|
||||||
onPlayArtist: suspend (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
if (albums.isNotEmpty()) {
|
|
||||||
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
|
|
||||||
}
|
|
||||||
if (artists.isNotEmpty()) {
|
|
||||||
val title = if (albums.isEmpty()) "You might like" else ""
|
|
||||||
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumsRow(
|
private fun AlbumsRow(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -969,14 +687,13 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
title: String,
|
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = title) {
|
HorizontalScrollRow(title = "Playlists") {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -984,19 +701,18 @@ private fun PlaylistsRow(
|
|||||||
icon = iconForPool(item.kind),
|
icon = iconForPool(item.kind),
|
||||||
onClick = { onPlayPool(item.kind) },
|
onClick = { onPlayPool(item.kind) },
|
||||||
)
|
)
|
||||||
is PlaylistRowItem.Real -> {
|
is PlaylistRowItem.Real -> PlaylistCard(
|
||||||
// Greyed offline when the tile needs the live server or
|
|
||||||
// isn't fully cached — dimmed but still tappable into the
|
|
||||||
// detail to shuffle whatever subset is cached.
|
|
||||||
val greyed = offline && item.playlist.unavailableOffline
|
|
||||||
PlaylistCard(
|
|
||||||
playlist = item.playlist,
|
playlist = item.playlist,
|
||||||
onClick = { onPlaylistClick(item.playlist.id) },
|
onClick = { onPlaylistClick(item.playlist.id) },
|
||||||
onPlay = { onPlayPlaylist(item.playlist) },
|
onPlay = { onPlayPlaylist(item.playlist) },
|
||||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
// Match Flutter: refreshable system playlists need
|
||||||
greyed = greyed,
|
// the live server endpoints, so disable their play
|
||||||
|
// overlay when offline (user can still tap into the
|
||||||
|
// detail and shuffle all from cache). User playlists
|
||||||
|
// play from cache + survive offline.
|
||||||
|
playEnabled = item.playlist.trackCount > 0 &&
|
||||||
|
!(offline && item.playlist.refreshable),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||||
label = item.label,
|
label = item.label,
|
||||||
variant = item.variant,
|
variant = item.variant,
|
||||||
@@ -1012,7 +728,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||||
internal sealed interface PlaylistRowItem {
|
private sealed interface PlaylistRowItem {
|
||||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||||
@@ -1025,105 +741,55 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||||
*
|
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||||
* Online: For You + Discover fixed slots (real card when generated,
|
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* exist — no placeholders for these since they're conditional on
|
||||||
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
* library shape, not guaranteed singletons. Finally user-owned
|
||||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
* playlists.
|
||||||
*
|
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
|
||||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
|
||||||
* (they need the server to generate, so they're meaningless offline).
|
|
||||||
*
|
*
|
||||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||||
* surfaces the secondary kinds on Home. Operator authorized the
|
* surfaces the secondary kinds on Home. Operator authorized the
|
||||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||||
*/
|
*/
|
||||||
internal fun buildPlaylistsRow(
|
private fun buildPlaylistsRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
): List<PlaylistRowItem> {
|
|
||||||
if (!offline) return buildOnlineRow(owned, status)
|
|
||||||
val out = mutableListOf<PlaylistRowItem>(
|
|
||||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
|
|
||||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
|
|
||||||
)
|
|
||||||
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
|
|
||||||
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
|
||||||
private fun buildOnlineRow(
|
|
||||||
owned: List<PlaylistRef>,
|
|
||||||
status: SystemPlaylistsStatus,
|
|
||||||
): List<PlaylistRowItem> {
|
): List<PlaylistRowItem> {
|
||||||
val out = mutableListOf<PlaylistRowItem>()
|
val out = mutableListOf<PlaylistRowItem>()
|
||||||
|
if (offline) {
|
||||||
|
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||||
|
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||||
|
}
|
||||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
|
||||||
|
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||||
|
out += songsLike.getOrNull(i)
|
||||||
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
|
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
// Secondary system kinds in server-registry order. Only included
|
||||||
|
// when actually generated — these depend on library shape (Deep
|
||||||
|
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||||
|
// so a missing one means "not enough data" rather than "still
|
||||||
|
// building".
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }
|
||||||
|
?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
|
||||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
|
||||||
*
|
|
||||||
* Online: every generated mix as a real card; when none exist yet, a few
|
|
||||||
* placeholders so the building / seed-needed state stays visible.
|
|
||||||
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
|
||||||
* an empty list — hiding the whole section — when nothing is cached.
|
|
||||||
*/
|
|
||||||
internal fun buildSongsLikeRow(
|
|
||||||
owned: List<PlaylistRef>,
|
|
||||||
status: SystemPlaylistsStatus,
|
|
||||||
offline: Boolean,
|
|
||||||
): List<PlaylistRowItem> {
|
|
||||||
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
|
||||||
return when {
|
|
||||||
offline -> {
|
|
||||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
|
||||||
(available + greyed).map { PlaylistRowItem.Real(it) }
|
|
||||||
}
|
|
||||||
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
|
||||||
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
|
||||||
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
|
||||||
* Songs-like is excluded here too — it renders in its own row via
|
|
||||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
|
||||||
*/
|
|
||||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
|
||||||
val out = mutableListOf<PlaylistRef>()
|
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
|
||||||
}
|
|
||||||
owned.filter { it.systemVariant == null }.forEach { out += it }
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||||
s.inFlight -> "building"
|
s.inFlight -> "building"
|
||||||
s.lastError != null -> "failed"
|
s.lastError != null -> "failed"
|
||||||
@@ -1131,10 +797,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
else -> "pending"
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
private const val SONGS_LIKE_SLOTS = 3
|
||||||
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
|
||||||
// once generated, are shown in full and no longer capped by this (#1491).
|
|
||||||
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 system playlist kinds the server generates that aren't pinned
|
* The 5 system playlist kinds the server generates that aren't pinned
|
||||||
|
|||||||
@@ -105,22 +105,6 @@ class LibraryRepository @Inject constructor(
|
|||||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||||
api.getArtistTracks(id).map { it.toDomain() }
|
api.getArtistTracks(id).map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
|
||||||
* strip. Network-only — non-critical UI, callers swallow failures
|
|
||||||
* and render nothing rather than blocking the screen.
|
|
||||||
*/
|
|
||||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
|
||||||
api.getSimilarArtists(id).map { it.toDomain() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
|
||||||
* panel. Network-only; tapping a row plays this exact list as a
|
|
||||||
* queue, so no persistence is needed.
|
|
||||||
*/
|
|
||||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
|
||||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,10 +116,9 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> ErrorRetry(
|
is AlbumDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -38,7 +36,6 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -67,14 +64,6 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
|
||||||
// load while the server was unreachable re-fetches when health
|
|
||||||
// returns, so coming back to the screen shows content, not an error.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is AlbumDetailUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+14
-118
@@ -1,5 +1,3 @@
|
|||||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
|
||||||
|
|
||||||
package com.fabledsword.minstrel.library.ui
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
@@ -20,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items as lazyItems
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -49,27 +46,21 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Play
|
import com.composables.icons.lucide.Play
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
|
||||||
import com.fabledsword.minstrel.models.albumCoverPath
|
import com.fabledsword.minstrel.models.albumCoverPath
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -107,38 +98,25 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> ErrorRetry(
|
is ArtistDetailUiState.Error -> EmptyState(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success -> {
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
val artistLiked by viewModel.artistLiked
|
||||||
}
|
.collectAsStateWithLifecycle()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ArtistSuccessBody(
|
|
||||||
state: ArtistDetailUiState.Success,
|
|
||||||
viewModel: ArtistDetailViewModel,
|
|
||||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
|
||||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
ArtistBody(
|
ArtistBody(
|
||||||
state = state,
|
detail = s.detail,
|
||||||
artistLiked = artistLiked,
|
artistLiked = artistLiked,
|
||||||
playingTrackId = playerState.currentTrack?.id,
|
|
||||||
onPlay = viewModel::playArtist,
|
onPlay = viewModel::playArtist,
|
||||||
onToggleLike = viewModel::toggleLikeArtist,
|
onToggleLike = viewModel::toggleLikeArtist,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onTopTrackClick = viewModel::playTopTracks,
|
|
||||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||||
@@ -149,16 +127,12 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistBody(
|
private fun ArtistBody(
|
||||||
state: ArtistDetailUiState.Success,
|
detail: ArtistDetailRef,
|
||||||
artistLiked: Boolean,
|
artistLiked: Boolean,
|
||||||
playingTrackId: String?,
|
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
onTopTrackClick: (Int) -> Unit,
|
|
||||||
onSimilarArtistClick: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val detail = state.detail
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -175,23 +149,6 @@ private fun ArtistBody(
|
|||||||
onToggleLike = onToggleLike,
|
onToggleLike = onToggleLike,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.topTracks.isNotEmpty()) {
|
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
|
||||||
TopTracksPanel(
|
|
||||||
tracks = state.topTracks,
|
|
||||||
playingTrackId = playingTrackId,
|
|
||||||
onTrackClick = onTopTrackClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.similarArtists.isNotEmpty()) {
|
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
|
||||||
SimilarArtistsStrip(
|
|
||||||
artists = state.similarArtists,
|
|
||||||
onArtistClick = onSimilarArtistClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (detail.albums.isEmpty()) {
|
if (detail.albums.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyAlbumsHint()
|
EmptyAlbumsHint()
|
||||||
@@ -204,67 +161,6 @@ private fun ArtistBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TopTracksPanel(
|
|
||||||
tracks: List<TrackRef>,
|
|
||||||
playingTrackId: String?,
|
|
||||||
onTrackClick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = "Top tracks",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
tracks.forEachIndexed { index, track ->
|
|
||||||
TopTrackRow(
|
|
||||||
track = track,
|
|
||||||
nowPlaying = track.id == playingTrackId,
|
|
||||||
onClick = { onTrackClick(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TopTrackRow(
|
|
||||||
track: TrackRef,
|
|
||||||
nowPlaying: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
TrackRow(
|
|
||||||
title = track.title,
|
|
||||||
artist = track.artistName,
|
|
||||||
trackId = track.id,
|
|
||||||
onClick = onClick,
|
|
||||||
nowPlaying = nowPlaying,
|
|
||||||
trailing = {
|
|
||||||
Text(
|
|
||||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SimilarArtistsStrip(
|
|
||||||
artists: List<ArtistRef>,
|
|
||||||
onArtistClick: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
HorizontalScrollRow(title = "Similar artists") {
|
|
||||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
|
||||||
ArtistCard(
|
|
||||||
artist = artist,
|
|
||||||
onClick = { onArtistClick(artist.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistHeader(
|
private fun ArtistHeader(
|
||||||
artist: ArtistRef,
|
artist: ArtistRef,
|
||||||
|
|||||||
+1
-65
@@ -5,13 +5,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -26,18 +23,13 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface ArtistDetailUiState {
|
sealed interface ArtistDetailUiState {
|
||||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||||
data class Success(
|
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||||
val detail: ArtistDetailRef,
|
|
||||||
val similarArtists: List<ArtistRef> = emptyList(),
|
|
||||||
val topTracks: List<TrackRef> = emptyList(),
|
|
||||||
) : ArtistDetailUiState
|
|
||||||
data class Error(val message: String) : ArtistDetailUiState
|
data class Error(val message: String) : ArtistDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +39,6 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -72,19 +63,6 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
|
||||||
// whole load; from Success, re-pull just the secondary sections —
|
|
||||||
// their fetch failures are swallowed to emptyList, so a dead zone
|
|
||||||
// during the first load leaves them permanently absent otherwise.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
when (internal.value) {
|
|
||||||
is ArtistDetailUiState.Error -> refresh()
|
|
||||||
is ArtistDetailUiState.Success -> loadSecondarySections()
|
|
||||||
is ArtistDetailUiState.Loading -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleLikeArtist() {
|
fun toggleLikeArtist() {
|
||||||
@@ -104,7 +82,6 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
loadSecondarySections()
|
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
@@ -112,31 +89,6 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
|
||||||
* and folds them into the live Success state. Each failure is
|
|
||||||
* swallowed (logged only) — these sections render absent rather than
|
|
||||||
* blocking, so the core artist view never depends on them.
|
|
||||||
*/
|
|
||||||
private suspend fun loadSecondarySections() {
|
|
||||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
|
||||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
|
||||||
val current = internal.value
|
|
||||||
if (current is ArtistDetailUiState.Success) {
|
|
||||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
|
||||||
) {
|
|
||||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull every track across the artist's albums, shuffle, and start
|
* Pull every track across the artist's albums, shuffle, and start
|
||||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||||
@@ -166,20 +118,4 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
|
||||||
* tracks are themselves the playable queue (mirrors the web client's
|
|
||||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
|
||||||
* list is already in the Success state.
|
|
||||||
*/
|
|
||||||
fun playTopTracks(index: Int) {
|
|
||||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
|
||||||
if (tracks.isEmpty()) return
|
|
||||||
player.setQueue(
|
|
||||||
tracks = tracks,
|
|
||||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
|
||||||
source = "artist-top:$artistId",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,11 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
syncController.lastSyncError,
|
) { artists, albums ->
|
||||||
) { artists, albums, syncError ->
|
if (artists.isEmpty() && albums.isEmpty()) {
|
||||||
when {
|
UiState.Empty
|
||||||
artists.isNotEmpty() || albums.isNotEmpty() ->
|
} else {
|
||||||
UiState.Success(LibraryData(artists, albums))
|
UiState.Success(LibraryData(artists, albums))
|
||||||
// Empty cache + failed sync is a load failure, not an empty
|
|
||||||
// library — surface it so the user gets a Retry instead of
|
|
||||||
// the welcome copy. Heals automatically: SyncController
|
|
||||||
// re-syncs on health recovery, clearing lastSyncError.
|
|
||||||
syncError != null -> UiState.Error(syncError)
|
|
||||||
else -> UiState.Empty
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
@@ -41,7 +38,6 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -50,7 +46,6 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
|||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -73,25 +68,14 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** Copy for the most recent refresh failure; null once one succeeds. */
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
|
||||||
// health returns, so a load that failed offline heals unprompted.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refresh() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
refreshError.value = null
|
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,16 +92,9 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
refreshError,
|
) { artists, albums, tracks ->
|
||||||
) { artists, albums, tracks, err ->
|
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
val sections = LikedSections(artists, albums, tracks)
|
||||||
when {
|
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
||||||
!sections.isAllEmpty -> UiState.Success(sections)
|
|
||||||
// Empty cache + failed refresh is a load failure, not "no
|
|
||||||
// likes yet" — surface Error so the user gets a Retry.
|
|
||||||
err != null -> UiState.Error(err)
|
|
||||||
else -> UiState.Empty
|
|
||||||
}
|
|
||||||
}.asCacheFirstStateFlow(viewModelScope)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,10 +130,9 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.models
|
|
||||||
|
|
||||||
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
|
||||||
data class AdminTagSourceRef(
|
|
||||||
val id: String,
|
|
||||||
val displayName: String,
|
|
||||||
val requiresApiKey: Boolean,
|
|
||||||
val supports: List<String>,
|
|
||||||
val enabled: Boolean,
|
|
||||||
val apiKeySet: Boolean,
|
|
||||||
val testable: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Result of a provider "test connection" call. */
|
|
||||||
data class TagSourceTestResult(
|
|
||||||
val ok: Boolean,
|
|
||||||
val durationMs: Long = 0,
|
|
||||||
val error: String = "",
|
|
||||||
)
|
|
||||||
@@ -20,8 +20,6 @@ data class PlaylistRef(
|
|||||||
val trackCount: Int = 0,
|
val trackCount: Int = 0,
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
val ownerUsername: String = "",
|
val ownerUsername: String = "",
|
||||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
|
||||||
val fullyCached: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val isSystem: Boolean get() = systemVariant != null
|
val isSystem: Boolean get() = systemVariant != null
|
||||||
|
|
||||||
@@ -32,13 +30,6 @@ data class PlaylistRef(
|
|||||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||||
*/
|
*/
|
||||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||||
|
|
||||||
/**
|
|
||||||
* Can't be relied on while offline — either it needs the live server to
|
|
||||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
|
||||||
* Callers combine this with the current offline state to grey the tile.
|
|
||||||
*/
|
|
||||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,11 +16,4 @@ data class UserRef(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
/**
|
|
||||||
* Account-level diagnostics opt-in (M9). NOT present in the login
|
|
||||||
* response (that's the narrow UserView) — populated by the /api/me
|
|
||||||
* refresh in AuthController so a remote admin flip reaches the
|
|
||||||
* device. Default false until the first refresh.
|
|
||||||
*/
|
|
||||||
val debugModeEnabled: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.models.wire
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
|
||||||
* Every field defaults defensively so a missing JSON key never crashes
|
|
||||||
* deserialization. `versionBumped` is present only on the PATCH response.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class AdminTagSourceWire(
|
|
||||||
val id: String = "",
|
|
||||||
@SerialName("display_name") val displayName: String = "",
|
|
||||||
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
|
||||||
val supports: List<String> = emptyList(),
|
|
||||||
val enabled: Boolean = false,
|
|
||||||
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
|
||||||
@SerialName("display_order") val displayOrder: Int = 0,
|
|
||||||
val testable: Boolean = false,
|
|
||||||
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AdminTagSourcesListWire(
|
|
||||||
val providers: List<AdminTagSourceWire> = emptyList(),
|
|
||||||
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TestTagSourceWire(
|
|
||||||
val ok: Boolean = false,
|
|
||||||
@SerialName("duration_ms") val durationMs: Long = 0,
|
|
||||||
val error: String = "",
|
|
||||||
)
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
@file:OptIn(ExperimentalSerializationApi::class)
|
|
||||||
|
|
||||||
package com.fabledsword.minstrel.models.wire
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
import kotlinx.serialization.EncodeDefault
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -19,16 +15,10 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayStartedRequest(
|
data class PlayStartedRequest(
|
||||||
// Discriminator MUST stay on the wire. The app's Json has
|
val type: String = "play_started",
|
||||||
// encodeDefaults=false, so without this a `type` left at its
|
|
||||||
// default is omitted entirely and the server 400s the event as
|
|
||||||
// "unknown event type" — silently dropping every native play.
|
|
||||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
|
|
||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
// #1551: device class for context conditioning (server normalizes).
|
|
||||||
@SerialName("device_class") val deviceClass: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -38,26 +28,24 @@ data class PlayStartedResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayEndedRequest(
|
data class PlayEndedRequest(
|
||||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
|
val type: String = "play_ended",
|
||||||
@SerialName("play_event_id") val playEventId: String,
|
@SerialName("play_event_id") val playEventId: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlaySkippedRequest(
|
data class PlaySkippedRequest(
|
||||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
val type: String = "play_skipped",
|
||||||
@SerialName("play_event_id") val playEventId: String,
|
@SerialName("play_event_id") val playEventId: String,
|
||||||
@SerialName("position_ms") val positionMs: Long,
|
@SerialName("position_ms") val positionMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayOfflineRequest(
|
data class PlayOfflineRequest(
|
||||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
val type: String = "play_offline",
|
||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
// #1551: device class for context conditioning (server normalizes).
|
|
||||||
@SerialName("device_class") val deviceClass: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ import kotlinx.serialization.Serializable
|
|||||||
* - rediscoverArtists → artist
|
* - rediscoverArtists → artist
|
||||||
* - mostPlayedTracks → track
|
* - mostPlayedTracks → track
|
||||||
* - lastPlayedArtists → artist
|
* - lastPlayedArtists → artist
|
||||||
* - youMightLikeAlbums → album
|
|
||||||
* - youMightLikeArtists → artist
|
|
||||||
*
|
*
|
||||||
* All slices default to empty so missing keys don't throw on decode —
|
* All slices default to empty so missing keys don't throw on decode.
|
||||||
* which also keeps the client safe against older servers that don't
|
|
||||||
* emit the you-might-like sections yet.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HomeIndexWire(
|
data class HomeIndexWire(
|
||||||
@@ -29,6 +25,4 @@ data class HomeIndexWire(
|
|||||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||||
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
|
||||||
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ data class MyProfileWire(
|
|||||||
@SerialName("display_name") val displayName: String? = null,
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val email: String? = null,
|
val email: String? = null,
|
||||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||||
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -191,13 +190,6 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable<AdminTagSources> {
|
|
||||||
WithAnimatedScope {
|
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
|
||||||
AdminTagSourcesScreen(navController = navController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
@Serializable data object AdminTagSources
|
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.PowerManager
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
|
||||||
*
|
|
||||||
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
|
||||||
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
|
||||||
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
|
||||||
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
|
||||||
* renderer and the queue-extend calls to the server. The poll then trips the
|
|
||||||
* drop threshold and playback falls back to a phone that *also* has no
|
|
||||||
* network — silence, while the renderer was streaming fine the whole time.
|
|
||||||
*
|
|
||||||
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
|
||||||
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
|
||||||
* coroutine scheduled. Both are acquired when a route goes active and released
|
|
||||||
* the moment it drops or the user switches back to the phone.
|
|
||||||
*
|
|
||||||
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
|
||||||
* synchronized, so they are safe to call from any thread.
|
|
||||||
*/
|
|
||||||
class CastNetworkLock(context: Context) {
|
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
|
|
||||||
private val wifiLock: WifiManager.WifiLock? =
|
|
||||||
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
|
||||||
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
|
||||||
?.apply { setReferenceCounted(false) }
|
|
||||||
|
|
||||||
private val wakeLock: PowerManager.WakeLock? =
|
|
||||||
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
|
||||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
|
||||||
?.apply { setReferenceCounted(false) }
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun acquire() {
|
|
||||||
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
|
||||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
|
||||||
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
|
||||||
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
|
||||||
Timber.w(
|
|
||||||
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
|
||||||
wifiLock?.isHeld, wakeLock?.isHeld,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun release() {
|
|
||||||
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
|
||||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
|
||||||
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
|
||||||
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
|
||||||
Timber.w("CastNetworkLock released")
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val LOCK_TAG = "minstrel:upnp-cast"
|
|
||||||
|
|
||||||
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
|
||||||
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
|
||||||
// the radio fully awake.
|
|
||||||
fun wifiLockMode(): Int =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-142
@@ -10,19 +10,14 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -44,15 +39,9 @@ import timber.log.Timber
|
|||||||
* wrapped ExoPlayer stays paused at the position it had when the
|
* wrapped ExoPlayer stays paused at the position it had when the
|
||||||
* route was selected.
|
* route was selected.
|
||||||
*
|
*
|
||||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
* factory wraps that callback into a SharedFlow consumed by the
|
||||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
* NowPlaying surface as a snackbar.
|
||||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
|
||||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
|
||||||
* two while the renderer is perfectly reachable, so commands retry on
|
|
||||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
|
||||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
|
||||||
* surface as a snackbar.
|
|
||||||
*
|
*
|
||||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
@@ -65,8 +54,6 @@ class MinstrelForwardingPlayer(
|
|||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val castNetworkLock: CastNetworkLock,
|
|
||||||
private val networkStatus: NetworkStatusController,
|
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -120,20 +107,6 @@ class MinstrelForwardingPlayer(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
}
|
}
|
||||||
scope.launch {
|
|
||||||
// When the phone's own network recovers, reconcile a held cast
|
|
||||||
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
|
||||||
// the renderer kept playing, so one round-trip re-confirms it and
|
|
||||||
// resyncs the cursor/position. Only a transition *into* Healthy
|
|
||||||
// while a route is active matters.
|
|
||||||
var prev = networkStatus.state.value
|
|
||||||
networkStatus.state.collect { next ->
|
|
||||||
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
|
||||||
pollTrigger.trySend(Unit)
|
|
||||||
}
|
|
||||||
prev = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||||
// addObserver requires it. The observer just trySend's to the channel.
|
// addObserver requires it. The observer just trySend's to the channel.
|
||||||
handler.post {
|
handler.post {
|
||||||
@@ -199,14 +172,13 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.play()
|
super.play()
|
||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(true)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.play() } }
|
runCatching { active.avTransport.play() }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPlaying()
|
remoteState.applyTransportPlaying()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleSoapFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,14 +193,13 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.pause()
|
super.pause()
|
||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(false)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.pause() } }
|
runCatching { active.avTransport.pause() }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPaused()
|
remoteState.applyTransportPaused()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleSoapFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,8 +222,8 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = remoteState.trackNumber,
|
trackNumber = remoteState.trackNumber,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
runCatching { active.avTransport.seek(positionMs) }
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleSoapFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,13 +253,11 @@ class MinstrelForwardingPlayer(
|
|||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
retryTransport {
|
|
||||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||||
if (positionMs > 0L) {
|
if (positionMs > 0L) {
|
||||||
active.avTransport.seek(positionMs)
|
active.avTransport.seek(positionMs)
|
||||||
}
|
}
|
||||||
}
|
}.onFailure { handleSoapFailure(active, it) }
|
||||||
}.onFailure { handleTransportFailure(active, it) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +280,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.next() } }
|
runCatching { active.avTransport.next() }
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleSoapFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,8 +303,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { retryTransport { active.avTransport.previous() } }
|
runCatching { active.avTransport.previous() }
|
||||||
.onFailure { handleTransportFailure(active, it) }
|
.onFailure { handleSoapFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,34 +385,11 @@ class MinstrelForwardingPlayer(
|
|||||||
super.release()
|
super.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
pollJob?.cancel()
|
||||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||||
* first socket I/O for a second or two; a retry lets the command land once
|
remoteState.applyError(t)
|
||||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
handler.post { onDrop(active.routeName) }
|
||||||
* renderer answered and rejected the action) is NOT retried -- the device
|
|
||||||
* is alive and retrying won't change its verdict.
|
|
||||||
*/
|
|
||||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
|
||||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A transport SOAP command failed even after retries. Deliberately does
|
|
||||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
|
||||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
|
||||||
* WiFi power-save can fail one command while the renderer is perfectly
|
|
||||||
* reachable; dropping on a single command falsely kicked playback back to
|
|
||||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
|
||||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
|
||||||
* truly gone, the poll loop trips the drop on its own.
|
|
||||||
*/
|
|
||||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
|
||||||
if (t is SoapFaultException) {
|
|
||||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
|
||||||
} else {
|
|
||||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
|
||||||
}
|
|
||||||
pollTrigger.trySend(Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||||
@@ -456,11 +402,6 @@ class MinstrelForwardingPlayer(
|
|||||||
lastNotifiedTrackIdx = -1
|
lastNotifiedTrackIdx = -1
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||||
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
|
||||||
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
|
||||||
// without this the radio power-saves on a locked screen and the
|
|
||||||
// poll below starves -- see [CastNetworkLock].
|
|
||||||
castNetworkLock.acquire()
|
|
||||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||||
// simultaneously with the remote renderer. handler.post targets the
|
// simultaneously with the remote renderer. handler.post targets the
|
||||||
// application looper, so this runs on the same thread that processes
|
// application looper, so this runs on the same thread that processes
|
||||||
@@ -470,27 +411,21 @@ class MinstrelForwardingPlayer(
|
|||||||
handler.post { delegate.pause() }
|
handler.post { delegate.pause() }
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
pollJob = scope.launch { pollLoop(active) }
|
||||||
} else {
|
} else {
|
||||||
castNetworkLock.release()
|
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
var networkDropSuppressed = false
|
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
networkDropSuppressed = false
|
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
if (networkStatus.state.value == ServerHealth.Healthy) {
|
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
handler.post { onDrop(active.routeName) }
|
handler.post { onDrop(active.routeName) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
|
||||||
}
|
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// resume). Whichever wins continues to the next pollOnce.
|
||||||
select<Unit> {
|
select<Unit> {
|
||||||
@@ -500,30 +435,6 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The poll-failure threshold tripped, but the phone's *own* network is down
|
|
||||||
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
|
||||||
* renderer right now," not "the renderer died." A UPnP renderer streams
|
|
||||||
* autonomously and is almost certainly still playing; meanwhile a local
|
|
||||||
* fallback couldn't reach the server either, so dropping would only blast
|
|
||||||
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
|
||||||
* the route and keep polling -- a successful poll once the network returns
|
|
||||||
* reconciles to the renderer's real state. Clear the failure streak so
|
|
||||||
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
|
||||||
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
|
||||||
* the rationale logs once per outage, not every tick.
|
|
||||||
*/
|
|
||||||
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
|
||||||
if (!alreadySuppressed) {
|
|
||||||
Timber.w(
|
|
||||||
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
|
||||||
active.routeName, networkStatus.state.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
@@ -602,39 +513,5 @@ class MinstrelForwardingPlayer(
|
|||||||
// when Sonos's reported Track matches the wrapped player; this only
|
// when Sonos's reported Track matches the wrapped player; this only
|
||||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||||
// Transport commands retry transient IO failures so a single WiFi
|
|
||||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
|
||||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
|
||||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
|
||||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
|
||||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
|
||||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
|
||||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
|
||||||
* answered and rejected the action -- propagates immediately: the device is
|
|
||||||
* alive, so retrying won't change its verdict. Extracted from
|
|
||||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
|
||||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
|
||||||
* playback to local audio).
|
|
||||||
*/
|
|
||||||
internal suspend fun <T> retryTransientIo(
|
|
||||||
attempts: Int,
|
|
||||||
backoffMs: Long,
|
|
||||||
block: suspend () -> T,
|
|
||||||
): T {
|
|
||||||
var attempt = 0
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return block()
|
|
||||||
} catch (io: IOException) {
|
|
||||||
attempt += 1
|
|
||||||
if (attempt >= attempts) throw io
|
|
||||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
|
||||||
delay(backoffMs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-19
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
|
|||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DataSpec
|
import androidx.media3.datasource.DataSpec
|
||||||
import androidx.media3.datasource.TransferListener
|
import androidx.media3.datasource.TransferListener
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InterruptedIOException
|
import java.io.InterruptedIOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataSource wrapper that fails the network read immediately when
|
* DataSource wrapper that fails the network read immediately when
|
||||||
* [NetworkStatusController] reports a gating state. CacheDataSource only
|
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
|
||||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||||
@@ -22,23 +22,10 @@ import java.io.InterruptedIOException
|
|||||||
*/
|
*/
|
||||||
class OfflineGatedDataSource(
|
class OfflineGatedDataSource(
|
||||||
private val delegate: DataSource,
|
private val delegate: DataSource,
|
||||||
private val health: NetworkStatusController,
|
private val health: ServerHealthController,
|
||||||
) : DataSource {
|
) : DataSource {
|
||||||
|
|
||||||
override fun open(dataSpec: DataSpec): Long {
|
override fun open(dataSpec: DataSpec): Long {
|
||||||
gateOnHealth()
|
|
||||||
return try {
|
|
||||||
val opened = delegate.open(dataSpec)
|
|
||||||
health.reportSuccess() // bytes flowing from the server == reachable
|
|
||||||
opened
|
|
||||||
} catch (e: IOException) {
|
|
||||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fast-fail before touching the network when the server can't be reached. */
|
|
||||||
private fun gateOnHealth() {
|
|
||||||
when (health.state.value) {
|
when (health.state.value) {
|
||||||
ServerHealth.Offline -> throw OfflineException(
|
ServerHealth.Offline -> throw OfflineException(
|
||||||
"Track not in the on-device cache and the device is offline.",
|
"Track not in the on-device cache and the device is offline.",
|
||||||
@@ -46,9 +33,9 @@ class OfflineGatedDataSource(
|
|||||||
ServerHealth.ServerDown -> throw OfflineException(
|
ServerHealth.ServerDown -> throw OfflineException(
|
||||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||||
)
|
)
|
||||||
// Unstable is non-gating: still try the network. Healthy too.
|
ServerHealth.Healthy -> Unit
|
||||||
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
|
|
||||||
}
|
}
|
||||||
|
return delegate.open(dataSpec)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() = delegate.close()
|
override fun close() = delegate.close()
|
||||||
@@ -62,7 +49,7 @@ class OfflineGatedDataSource(
|
|||||||
|
|
||||||
class OfflineGatedDataSourceFactory(
|
class OfflineGatedDataSourceFactory(
|
||||||
private val upstream: DataSource.Factory,
|
private val upstream: DataSource.Factory,
|
||||||
private val health: NetworkStatusController,
|
private val health: ServerHealthController,
|
||||||
) : DataSource.Factory {
|
) : DataSource.Factory {
|
||||||
override fun createDataSource(): DataSource =
|
override fun createDataSource(): DataSource =
|
||||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
|
||||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
@@ -65,24 +64,11 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
private var curLastPositionMs: Long = 0
|
private var curLastPositionMs: Long = 0
|
||||||
private var curDurationMs: Long = 0
|
private var curDurationMs: Long = 0
|
||||||
|
|
||||||
// Whether the currently-tracked play is actively playing (vs paused).
|
|
||||||
// onStop reads this: a backgrounded play that's still playing is left
|
|
||||||
// to the live path (the foreground service keeps us alive), while a
|
|
||||||
// paused one is durably closed so a kill can't orphan its open row.
|
|
||||||
private var curPlaying: Boolean = false
|
|
||||||
|
|
||||||
// Server play_event_id when the live play_started succeeded;
|
// Server play_event_id when the live play_started succeeded;
|
||||||
// null if start failed/offline — then the close is captured into
|
// null if start failed/offline — then the close is captured into
|
||||||
// the offline mutation queue instead of a live ended/skipped.
|
// the offline mutation queue instead of a live ended/skipped.
|
||||||
private var openPlayEventId: String? = null
|
private var openPlayEventId: String? = null
|
||||||
|
|
||||||
// True from beginTrack until startLive's play_started resolves. When a
|
|
||||||
// close races an in-flight start (id still null), the server row is
|
|
||||||
// being created but we've lost its id — we must NOT also enqueue an
|
|
||||||
// offline play (that would duplicate); the server auto-closes the
|
|
||||||
// orphan on the next play_started instead.
|
|
||||||
private var startInFlight: Boolean = false
|
|
||||||
|
|
||||||
private var prevTrackId: String? = null
|
private var prevTrackId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -109,7 +95,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Track-change → close the prior tracked play.
|
// Track-change → close the prior tracked play.
|
||||||
if (trackId != prevTrackId && curTrackId != null) {
|
if (trackId != prevTrackId && curTrackId != null) {
|
||||||
closeCurrent()
|
closeCurrent(viaOffline = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entered playing for a new track → begin tracking it.
|
// Entered playing for a new track → begin tracking it.
|
||||||
@@ -124,7 +110,6 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
if (curTrackId != null && trackId == curTrackId) {
|
if (curTrackId != null && trackId == curTrackId) {
|
||||||
curLastPositionMs = positionMs
|
curLastPositionMs = positionMs
|
||||||
if (durationMs > 0) curDurationMs = durationMs
|
if (durationMs > 0) curDurationMs = durationMs
|
||||||
curPlaying = playing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prevTrackId = trackId
|
prevTrackId = trackId
|
||||||
@@ -136,18 +121,12 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
curSource = source
|
curSource = source
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = durationMs
|
curDurationMs = durationMs
|
||||||
curPlaying = true
|
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
startInFlight = true
|
|
||||||
startLive(trackId = trackId, source = source)
|
startLive(trackId = trackId, source = source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLive(trackId: String, source: String?) {
|
private fun startLive(trackId: String, source: String?) {
|
||||||
val cid = resolveClientId()
|
val cid = resolveClientId() ?: return
|
||||||
if (cid == null) {
|
|
||||||
startInFlight = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val response = api.playStarted(
|
val response = api.playStarted(
|
||||||
@@ -155,7 +134,6 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
deviceClass = DEVICE_CLASS,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -166,61 +144,47 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Offline / flaky — openPlayEventId stays null; close
|
// Offline / flaky — openPlayEventId stays null; close
|
||||||
// path will enqueue the completed play for replay.
|
// path will enqueue the completed play for replay.
|
||||||
} finally {
|
|
||||||
startInFlight = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun closeCurrent(viaOffline: Boolean) {
|
||||||
* Closes the tracked play. Routes by whether the server already has an
|
|
||||||
* open row, NOT by caller context — this is what keeps background /
|
|
||||||
* track-change / failure paths from each minting a second row:
|
|
||||||
* - open server row (id) → close it by id; on failure enqueue a durable
|
|
||||||
* close-by-id (never an offline play).
|
|
||||||
* - start in flight (no id yet) → leave it; the server auto-closes the
|
|
||||||
* orphan on the next play_started. Enqueuing offline here would
|
|
||||||
* duplicate the in-flight row.
|
|
||||||
* - no server row at all → record the whole play offline.
|
|
||||||
*
|
|
||||||
* Always uses the actual position played; the server's skip rule
|
|
||||||
* (50%/30s defaults) classifies was_skipped from it.
|
|
||||||
*/
|
|
||||||
private fun closeCurrent() {
|
|
||||||
val trackId = curTrackId
|
val trackId = curTrackId
|
||||||
val startedAt = curStartedAt
|
val startedAt = curStartedAt
|
||||||
if (trackId == null || startedAt == null) {
|
if (trackId == null || startedAt == null) {
|
||||||
resetCurrent()
|
resetCurrent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Always send the actual position played to; the server's
|
||||||
|
// skip rule (50%/30s defaults, configurable) classifies
|
||||||
|
// was_skipped from this. If the track ran to completion the
|
||||||
|
// last observed position is ~= duration, which gives ratio ~1
|
||||||
|
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
||||||
|
// rounding the prior shape did is no longer needed — the
|
||||||
|
// server's threshold is the source of truth.
|
||||||
val durationMs = curLastPositionMs
|
val durationMs = curLastPositionMs
|
||||||
val source = curSource
|
val source = curSource
|
||||||
val id = openPlayEventId
|
val id = openPlayEventId
|
||||||
val inFlight = startInFlight
|
|
||||||
when {
|
|
||||||
id != null -> closeById(id, durationMs)
|
|
||||||
inFlight -> Unit
|
|
||||||
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
|
||||||
}
|
|
||||||
resetCurrent()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun closeById(id: String, durationMs: Long) {
|
if (!viaOffline && id != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
api.playEnded(
|
api.playEnded(
|
||||||
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
PlayEndedRequest(
|
||||||
|
playEventId = id,
|
||||||
|
durationPlayedMs = durationMs,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
) {
|
) {
|
||||||
// Durable close-by-id (idempotent server-side) — survives a
|
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||||
// process kill without orphaning the open row or duplicating.
|
|
||||||
mutationQueue.enqueuePlayEnded(
|
|
||||||
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||||
|
}
|
||||||
|
resetCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetCurrent() {
|
private fun resetCurrent() {
|
||||||
@@ -229,9 +193,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
curSource = null
|
curSource = null
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = 0
|
curDurationMs = 0
|
||||||
curPlaying = false
|
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
startInFlight = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueOffline(
|
private fun enqueueOffline(
|
||||||
@@ -249,7 +211,6 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
deviceClass = DEVICE_CLASS,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -270,24 +231,9 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
companion object {
|
|
||||||
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
|
||||||
// configuration later if the metrics trend view shows it matters.
|
|
||||||
private const val DEVICE_CLASS = "mobile"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
if (curTrackId != null) {
|
||||||
// alone: the foreground media service keeps the process — and this
|
closeCurrent(viaOffline = true)
|
||||||
// app-scope uiState collector — alive, so the live state machine
|
|
||||||
// closes the play accurately on the next real track change. Closing
|
|
||||||
// here (the old always-offline behavior) raced the collector's
|
|
||||||
// re-begin and minted a duplicate, skip-misclassified row on every
|
|
||||||
// screen-lock. Only durably close a PAUSED play, where no further
|
|
||||||
// progress will happen and a process kill would otherwise orphan the
|
|
||||||
// open server row. See docs/superpowers/2026-06-11 contract audit.
|
|
||||||
if (curTrackId != null && !curPlaying) {
|
|
||||||
closeCurrent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.fabledsword.minstrel.player
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -41,7 +40,6 @@ private const val DEBOUNCE_MS = 2_000L
|
|||||||
class PlaybackErrorReporter @Inject constructor(
|
class PlaybackErrorReporter @Inject constructor(
|
||||||
private val playerController: PlayerController,
|
private val playerController: PlayerController,
|
||||||
private val repository: PlaybackErrorRepository,
|
private val repository: PlaybackErrorRepository,
|
||||||
private val networkStatus: NetworkStatusController,
|
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||||
@@ -54,10 +52,6 @@ class PlaybackErrorReporter @Inject constructor(
|
|||||||
val buffer = mutableListOf<String>()
|
val buffer = mutableListOf<String>()
|
||||||
var debounceJob: kotlinx.coroutines.Job? = null
|
var debounceJob: kotlinx.coroutines.Job? = null
|
||||||
playerController.playbackErrorEvents.collect { event ->
|
playerController.playbackErrorEvents.collect { event ->
|
||||||
// A track failing to play is ambiguous (dead server vs. one bad
|
|
||||||
// file) — let the controller arbitrate via /healthz. No-op when
|
|
||||||
// already Offline; cheap otherwise.
|
|
||||||
networkStatus.reportFailure()
|
|
||||||
// Fire-and-forget the server report — repository handles
|
// Fire-and-forget the server report — repository handles
|
||||||
// success/queue branching so callers don't see throws.
|
// success/queue branching so callers don't see throws.
|
||||||
scope.launch { repository.report(event) }
|
scope.launch { repository.report(event) }
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|
||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -65,7 +63,6 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
private val playlists: PlaylistsRepository,
|
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -114,13 +111,6 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
private var lastEvaluatedItemIndex: Int = -1
|
||||||
|
|
||||||
/**
|
|
||||||
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
|
||||||
* the last successful play. Capped so a still-broken refresh can't loop;
|
|
||||||
* reset when any track loads with real audio.
|
|
||||||
*/
|
|
||||||
private var selfHealAttempts = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -202,20 +192,6 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
|
||||||
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
|
||||||
* playing our queue, we sync the local cursor to the renderer's current
|
|
||||||
* track/position so skip/seek and the UI map onto its live session — the
|
|
||||||
* renderer keeps the audio, the local player stays paused. Unlike
|
|
||||||
* [seekToIndex] this issues no play().
|
|
||||||
*/
|
|
||||||
fun moveCursorTo(index: Int, positionMs: Long) {
|
|
||||||
val controller = mediaController ?: return
|
|
||||||
if (index !in queueRefs.indices) return
|
|
||||||
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the queue with [tracks] starting at [initialIndex].
|
* Replace the queue with [tracks] starting at [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||||
@@ -361,12 +337,7 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) {
|
if (!isZeroDuration) return
|
||||||
// A track loaded with real audio — clear the self-heal budget so
|
|
||||||
// a later stale queue can recover again.
|
|
||||||
selfHealAttempts = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playbackErrorEventsChannel.trySend(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
trackId = current.id,
|
||||||
@@ -382,58 +353,6 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #968: the queue is fully unplayable (every track failed to load). If it
|
|
||||||
* came from a refreshable source — a system playlist (bare-variant source)
|
|
||||||
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
|
||||||
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
|
||||||
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
|
||||||
* sources have nothing to refresh — stop.
|
|
||||||
*/
|
|
||||||
private fun selfHealOrStop(controller: MediaController) {
|
|
||||||
val source = controller.currentMediaItem
|
|
||||||
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
|
||||||
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
|
||||||
controller.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
source.startsWith("radio:") -> {
|
|
||||||
selfHealAttempts++
|
|
||||||
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
|
||||||
}
|
|
||||||
!source.contains(':') -> { // bare variant = refreshable system playlist
|
|
||||||
selfHealAttempts++
|
|
||||||
scope.launch { selfHealSystem(source) }
|
|
||||||
}
|
|
||||||
else -> controller.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun selfHealSystem(variant: String) {
|
|
||||||
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
if (refs.isEmpty()) {
|
|
||||||
stopOnControllerThread()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setQueue(refs, initialIndex = 0, source = variant)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun selfHealRadio(seedTrackId: String) {
|
|
||||||
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
|
||||||
if (refs.isEmpty()) {
|
|
||||||
stopOnControllerThread()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopOnControllerThread() {
|
|
||||||
val controller = mediaController ?: return
|
|
||||||
runOnControllerThread(controller) { controller.stop() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -484,17 +403,6 @@ class PlayerController @Inject constructor(
|
|||||||
detail = error.message,
|
detail = error.message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// A failed load leaves the player IDLE; advance past the bad
|
|
||||||
// track and re-prepare so one unplayable item doesn't strand
|
|
||||||
// playback (mirrors the zero_duration skip). At the end of a
|
|
||||||
// fully-unplayable queue, try to self-heal a stale refreshable
|
|
||||||
// source before stopping.
|
|
||||||
if (controller.hasNextMediaItem()) {
|
|
||||||
controller.seekToNextMediaItem()
|
|
||||||
controller.prepare()
|
|
||||||
} else {
|
|
||||||
selfHealOrStop(controller)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(
|
override fun onMediaItemTransition(
|
||||||
@@ -841,10 +749,6 @@ class PlayerController @Inject constructor(
|
|||||||
// they don't need this poll.
|
// they don't need this poll.
|
||||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||||
|
|
||||||
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
|
||||||
// the next successful play) so a still-broken refresh can't loop.
|
|
||||||
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
private val cacheConfig: CacheConfig,
|
private val cacheConfig: CacheConfig,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||||
) {
|
) {
|
||||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
@@ -81,8 +81,6 @@ class PlayerFactory @Inject constructor(
|
|||||||
delegate = exo,
|
delegate = exo,
|
||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
castNetworkLock = CastNetworkLock(context),
|
|
||||||
networkStatus = serverHealth,
|
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import javax.inject.Singleton
|
|||||||
* Updates flow in from:
|
* Updates flow in from:
|
||||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||||
* - Poll failures -> recordPollFailure()
|
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||||
*
|
*
|
||||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||||
* consecutive poll failures = remote considered dropped (returns true
|
* consecutive poll failures = remote considered dropped (returns true
|
||||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
* from recordPollFailure for the caller to surface). Success resets it.
|
||||||
* Failed transport commands no longer drop the route -- they retry and
|
|
||||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class RemotePlayerState @Inject constructor() {
|
class RemotePlayerState @Inject constructor() {
|
||||||
@@ -29,13 +27,6 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
@Volatile var lastError: Throwable? = null; private set
|
@Volatile var lastError: Throwable? = null; private set
|
||||||
@Volatile var trackNumber: Int = 0; private set
|
@Volatile var trackNumber: Int = 0; private set
|
||||||
|
|
||||||
// The operator's last explicit transport intent (play=true, pause=false),
|
|
||||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
|
||||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
|
||||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
|
||||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
|
||||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
|
||||||
|
|
||||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||||
@@ -72,8 +63,6 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
|
||||||
|
|
||||||
fun applyError(t: Throwable) {
|
fun applyError(t: Throwable) {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
lastError = t
|
lastError = t
|
||||||
@@ -91,7 +80,6 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
lastPlayIntent = false
|
|
||||||
currentTrackUri = ""
|
currentTrackUri = ""
|
||||||
lastError = null
|
lastError = null
|
||||||
consecutivePollFailures = 0
|
consecutivePollFailures = 0
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.player.output
|
|
||||||
|
|
||||||
/** Action the idle-revert collector should take on a player-state change. */
|
|
||||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
|
||||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
|
||||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
|
||||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
|
||||||
* otherwise IGNORE.
|
|
||||||
*/
|
|
||||||
internal fun idleRevertAction(
|
|
||||||
upnpEngaged: Boolean,
|
|
||||||
isPlaying: Boolean,
|
|
||||||
armed: Boolean,
|
|
||||||
): IdleRevertAction = when {
|
|
||||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
|
||||||
armed -> IdleRevertAction.CANCEL
|
|
||||||
else -> IdleRevertAction.IGNORE
|
|
||||||
}
|
|
||||||
+5
-202
@@ -15,12 +15,10 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -30,7 +28,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
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
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -150,12 +147,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
// can run the longest-common-prefix / common-suffix diff.
|
// can run the longest-common-prefix / common-suffix diff.
|
||||||
private var lastSyncedQueueIds: List<String>? = null
|
private var lastSyncedQueueIds: List<String>? = null
|
||||||
|
|
||||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
|
||||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
|
||||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
|
||||||
// See idleRevertAction for the arm/cancel decision.
|
|
||||||
private var idleRevertJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Two-arg addCallback registers with no discovery flag —
|
// Two-arg addCallback registers with no discovery flag —
|
||||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||||
@@ -164,8 +155,7 @@ class OutputPickerController @Inject constructor(
|
|||||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||||
mediaRouter.addCallback(selector, callback)
|
mediaRouter.addCallback(selector, callback)
|
||||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||||
// the sole drop arbiter; a failed transport command no longer drops),
|
|
||||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||||
// path but skips the Stop SOAP since the device is already unreachable.
|
// path but skips the Stop SOAP since the device is already unreachable.
|
||||||
@@ -173,79 +163,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
scope.launch { observeIdleRevertWhileUpnp() }
|
|
||||||
scope.launch { observeSelectedRouteDisappearance() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revert the UPnP selection to the phone when the selected renderer leaves
|
|
||||||
* discovery (powered off / off the LAN) while we are NOT actively casting
|
|
||||||
* to it. Without this the selection could pin to a ghost route, so a later
|
|
||||||
* "play" would target a speaker that's gone. An *active* cast is left alone
|
|
||||||
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
|
||||||
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
|
||||||
* post-picker-scan prune; this reacts to the resulting routes change.
|
|
||||||
*/
|
|
||||||
private suspend fun observeSelectedRouteDisappearance() {
|
|
||||||
upnpDiscovery.routes.collect { routes ->
|
|
||||||
val selected = selectedUpnpRouteIdInternal.value
|
|
||||||
val present = routes.any { it.id == selected }
|
|
||||||
val casting = activeUpnpHolder.active.value?.routeId == selected
|
|
||||||
if (selected != null && !present && !casting) {
|
|
||||||
Timber.w(
|
|
||||||
"Output: selected UPnP route %s left discovery; reverting to phone",
|
|
||||||
selected,
|
|
||||||
)
|
|
||||||
selectedUpnpRouteIdInternal.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
|
||||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
|
||||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
|
||||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
|
||||||
*/
|
|
||||||
private suspend fun observeIdleRevertWhileUpnp() {
|
|
||||||
playerController.uiState.collect { state ->
|
|
||||||
val engaged = activeUpnpHolder.active.value != null ||
|
|
||||||
activeUpnpHolder.target.value != null
|
|
||||||
val armed = idleRevertJob?.isActive == true
|
|
||||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
|
||||||
IdleRevertAction.ARM ->
|
|
||||||
idleRevertJob = scope.launch {
|
|
||||||
delay(IDLE_REVERT_MS)
|
|
||||||
revertToPhoneOnIdle()
|
|
||||||
}
|
|
||||||
IdleRevertAction.CANCEL -> {
|
|
||||||
idleRevertJob?.cancel()
|
|
||||||
idleRevertJob = null
|
|
||||||
}
|
|
||||||
IdleRevertAction.IGNORE -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
|
||||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
|
||||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
|
||||||
* seek local ExoPlayer to the last remote position. No play(): the
|
|
||||||
* operator was paused, so we revert routing silently. Guarded by the
|
|
||||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
|
||||||
*/
|
|
||||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
|
||||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
|
||||||
val active = activeUpnpHolder.active.value
|
|
||||||
val capturedPositionMs = remoteState.positionMs
|
|
||||||
runCatching { active?.avTransport?.stop() }
|
|
||||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
|
||||||
activeUpnpHolder.set(null)
|
|
||||||
activeUpnpHolder.setTarget(null)
|
|
||||||
selectedUpnpRouteIdInternal.value = null
|
|
||||||
idleRevertJob = null
|
|
||||||
playerController.seekTo(capturedPositionMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,19 +365,16 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||||
* consecutive-failure threshold tripping). Captures the last remote
|
* threshold or SOAP exception). Captures the last remote position +
|
||||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||||
* The snackbar is handled independently by the NowPlaying surface
|
* The snackbar is handled independently by the NowPlaying surface
|
||||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||||
*/
|
*/
|
||||||
private fun handleRemoteDrop() {
|
private fun handleRemoteDrop() {
|
||||||
val capturedPositionMs = remoteState.positionMs
|
val capturedPositionMs = remoteState.positionMs
|
||||||
// Resume on the operator's last play-intent, not the observed remote
|
val wasPlayingRemote = remoteState.isPlaying
|
||||||
// state: after a pause, isPlaying is false, but a failed play() that
|
|
||||||
// triggered this drop means the operator DID ask to play -- honor it.
|
|
||||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
activeUpnpHolder.setTarget(null)
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
@@ -568,13 +482,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
// If the renderer is already playing this exact queue (a "stray"
|
|
||||||
// session we got disconnected from), attach to it in place instead of
|
|
||||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
|
||||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
|
||||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
|
||||||
return@withLock
|
|
||||||
}
|
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
// silently while target is set but active is null -- the user's premature
|
// silently while target is set but active is null -- the user's premature
|
||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
@@ -590,9 +497,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
rendering = rendering,
|
rendering = rendering,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Selecting a route loads the queue and Plays on the renderer, so
|
|
||||||
// the standing intent is "play" -- a later drop should resume local.
|
|
||||||
remoteState.setPlayIntent(true)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
@@ -601,102 +505,6 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The renderer's current position + transport state, validated as an
|
|
||||||
* adoptable continuation of our local queue: same track id at the same
|
|
||||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
|
||||||
*/
|
|
||||||
private data class AdoptPlan(
|
|
||||||
val localIndex: Int,
|
|
||||||
val positionMs: Long,
|
|
||||||
val durationMs: Long,
|
|
||||||
val trackUri: String,
|
|
||||||
val trackNumber: Int,
|
|
||||||
val playing: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach to a renderer that is *already* playing our queue (a session the
|
|
||||||
* phone got disconnected from but the speaker kept streaming) instead of
|
|
||||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
|
||||||
* mid-playback on the same track id at the same queue index, syncs the
|
|
||||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
|
||||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
|
||||||
* the caller does the full clear+reload) when the renderer isn't playing
|
|
||||||
* our queue. The local player is already paused by the caller.
|
|
||||||
*/
|
|
||||||
private suspend fun tryAdoptRunningSession(
|
|
||||||
transport: AVTransportClient,
|
|
||||||
rendering: RenderingControlClient?,
|
|
||||||
route: OutputRoute,
|
|
||||||
localQueue: List<TrackRef>,
|
|
||||||
): Boolean {
|
|
||||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
|
||||||
Timber.w(
|
|
||||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
|
||||||
route.name, plan.trackNumber, plan.positionMs,
|
|
||||||
)
|
|
||||||
// active is still null here, so this aligns the *local* cursor without
|
|
||||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
|
||||||
// reconciles to the renderer's advancing position from here.
|
|
||||||
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
|
||||||
remoteState.applyPositionInfo(
|
|
||||||
positionMs = plan.positionMs,
|
|
||||||
durationMs = plan.durationMs,
|
|
||||||
trackUri = plan.trackUri,
|
|
||||||
trackNumber = plan.trackNumber,
|
|
||||||
)
|
|
||||||
remoteState.setPlayIntent(plan.playing)
|
|
||||||
activeUpnpHolder.set(
|
|
||||||
ActiveUpnp(
|
|
||||||
routeId = route.id,
|
|
||||||
routeName = route.name,
|
|
||||||
avTransport = transport,
|
|
||||||
rendering = rendering,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun probeAdoptable(
|
|
||||||
transport: AVTransportClient,
|
|
||||||
route: OutputRoute,
|
|
||||||
localQueue: List<TrackRef>,
|
|
||||||
): AdoptPlan? {
|
|
||||||
val probe = runCatching {
|
|
||||||
transport.getTransportInfo() to transport.getPositionInfo()
|
|
||||||
}.getOrElse {
|
|
||||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val (info, pos) = probe
|
|
||||||
val idx0 = pos.track - 1
|
|
||||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
|
||||||
val midPlayback =
|
|
||||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
|
||||||
val aligned = midPlayback && playingId != null &&
|
|
||||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
|
||||||
return if (aligned) {
|
|
||||||
AdoptPlan(
|
|
||||||
localIndex = idx0,
|
|
||||||
positionMs = pos.relTimeMs,
|
|
||||||
durationMs = pos.trackDurationMs,
|
|
||||||
trackUri = pos.trackUri,
|
|
||||||
trackNumber = pos.track,
|
|
||||||
playing = info.state == TransportState.PLAYING,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
|
||||||
private fun trackIdFromStreamUrl(url: String): String? {
|
|
||||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
|
||||||
val i = segments.indexOf("tracks")
|
|
||||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadQueueOnSonos(
|
private suspend fun loadQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
@@ -832,10 +640,5 @@ class OutputPickerController @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
const val EXTEND_THROTTLE_MS = 50L
|
||||||
|
|
||||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
|
||||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
|
||||||
// a later "tap play" do nothing.
|
|
||||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -133,7 +133,7 @@ private fun RouteRow(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
val subtitle = route.description ?: defaultSubtitle(route)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -198,13 +198,10 @@ private fun MulticastHintRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSelected (route == the active/selected route) drives the Bluetooth
|
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||||
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
|
|
||||||
// DISCONNECTED for local SYSTEM routes even when they're in use.
|
|
||||||
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
|
|
||||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
OutputRoute.Kind.Wired -> "Wired"
|
||||||
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
|
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||||
OutputRoute.Kind.Cast -> "Cast"
|
OutputRoute.Kind.Cast -> "Cast"
|
||||||
OutputRoute.Kind.Other -> "Available"
|
OutputRoute.Kind.Other -> "Available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ data class OutputRoute(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
val protocol: Protocol,
|
val protocol: Protocol,
|
||||||
|
val isConnected: Boolean,
|
||||||
) {
|
) {
|
||||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||||
|
|
||||||
@@ -42,11 +43,9 @@ data class OutputRoute(
|
|||||||
/**
|
/**
|
||||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||||
* device types fall through to [Kind.Other]. "Active" is NOT an
|
* device types fall through to [Kind.Other]. The
|
||||||
* intrinsic of the route — the caller derives it by comparing to
|
* `connectionState` proxy is good enough for the chip's
|
||||||
* the selected route (RouteSnapshot.current), because
|
* "Connected"/"Available" subtitle.
|
||||||
* RouteInfo.connectionState only reflects remote-route handshakes
|
|
||||||
* and stays DISCONNECTED for local SYSTEM routes even when in use.
|
|
||||||
*/
|
*/
|
||||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||||
val kind = when (route.deviceType) {
|
val kind = when (route.deviceType) {
|
||||||
@@ -59,12 +58,15 @@ data class OutputRoute(
|
|||||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||||
else -> Kind.Other
|
else -> Kind.Other
|
||||||
}
|
}
|
||||||
|
val connected =
|
||||||
|
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||||
return OutputRoute(
|
return OutputRoute(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
protocol = Protocol.SYSTEM,
|
protocol = Protocol.SYSTEM,
|
||||||
|
isConnected = connected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +76,10 @@ data class OutputRoute(
|
|||||||
* network speakers into the same `OutputPickerController`
|
* network speakers into the same `OutputPickerController`
|
||||||
* routes stream the system routes come through.
|
* routes stream the system routes come through.
|
||||||
*
|
*
|
||||||
* UPnP devices have no MediaRouter connection-state concept —
|
* `isConnected = false` because UPnP devices have no
|
||||||
* they're always "available" on the LAN, and the picker's
|
* MediaRouter connection-state concept — they're always
|
||||||
* selected-route rendering handles the "currently playing"
|
* "available" on the LAN, and the picker's selected-route
|
||||||
* indicator.
|
* rendering handles the "currently playing" indicator.
|
||||||
*
|
*
|
||||||
* Subtitle is `manufacturer modelName` joined by a single
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
* space, falling back to "Network speaker" when both fields
|
* space, falling back to "Network speaker" when both fields
|
||||||
@@ -94,6 +96,7 @@ data class OutputRoute(
|
|||||||
description = description,
|
description = description,
|
||||||
kind = Kind.Other,
|
kind = Kind.Other,
|
||||||
protocol = Protocol.UPNP,
|
protocol = Protocol.UPNP,
|
||||||
|
isConnected = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-55
@@ -8,7 +8,6 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -18,8 +17,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -48,24 +45,9 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
private val ssdp = SsdpDiscovery(context)
|
private val ssdp = SsdpDiscovery(context)
|
||||||
|
|
||||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
|
||||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
|
||||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
|
||||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
|
||||||
// dispatcher stay shared with the app client.
|
|
||||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
|
||||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
|
||||||
// post-active-scan pruning so a powered-off renderer leaves the picker
|
|
||||||
// (and can't pin a stale selection) instead of lingering for the process
|
|
||||||
// lifetime -- upsert only ever added before.
|
|
||||||
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
|
||||||
|
|
||||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -86,31 +68,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* that just opened).
|
* that just opened).
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
fun upgradeDiscovery() {
|
||||||
appScope.launch {
|
appScope.launch { ssdp.requestActiveScan() }
|
||||||
val scanStartMs = System.currentTimeMillis()
|
|
||||||
ssdp.requestActiveScan()
|
|
||||||
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
|
||||||
// description fetch), then drop any route that didn't re-announce.
|
|
||||||
// Pruning is tied to picker-open scans only -- no background timer,
|
|
||||||
// so route rows never flicker while the user isn't looking.
|
|
||||||
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
|
||||||
pruneRoutesNotSeenSince(scanStartMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
|
||||||
* -- i.e. they didn't answer the active scan that just ran, so they're
|
|
||||||
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
|
||||||
*/
|
|
||||||
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
|
||||||
val before = routesInternal.value
|
|
||||||
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
|
||||||
if (survivors.size == before.size) return
|
|
||||||
val removed = before.filterNot { it in survivors }.map { it.id }
|
|
||||||
removed.forEach { lastSeenMs.remove(it) }
|
|
||||||
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
|
||||||
routesInternal.value = survivors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +91,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
fun transportFor(routeId: String): AVTransportClient? {
|
fun transportFor(routeId: String): AVTransportClient? {
|
||||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||||
return AVTransportClient(
|
return AVTransportClient(
|
||||||
loggingSoapClient(controlHttp, route.name),
|
loggingSoapClient(okHttp, route.name),
|
||||||
route.avTransportControlUrl,
|
route.avTransportControlUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,7 +105,6 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertRoute(route: UpnpRoute) {
|
private fun upsertRoute(route: UpnpRoute) {
|
||||||
lastSeenMs[route.id] = System.currentTimeMillis()
|
|
||||||
val current = routesInternal.value
|
val current = routesInternal.value
|
||||||
val idx = current.indexOfFirst { it.id == route.id }
|
val idx = current.indexOfFirst { it.id == route.id }
|
||||||
routesInternal.value = if (idx < 0) {
|
routesInternal.value = if (idx < 0) {
|
||||||
@@ -268,16 +225,6 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// devices append. Anchored to end-of-string so it never eats
|
// devices append. Anchored to end-of-string so it never eats
|
||||||
// a legitimate parenthetical inside a name.
|
// a legitimate parenthetical inside a name.
|
||||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||||
|
|
||||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
|
||||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
|
||||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
|
||||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
|
||||||
|
|
||||||
// Grace window after an M-SEARCH burst before pruning non-responders.
|
|
||||||
// Covers the SSDP round-trip plus our per-device description fetch;
|
|
||||||
// generous enough that a present-but-slow renderer isn't false-pruned.
|
|
||||||
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-28
@@ -7,7 +7,6 @@ import retrofit2.HttpException
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
@@ -19,7 +18,6 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
|||||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -53,16 +51,9 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||||
|
|
||||||
/**
|
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
|
||||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
|
||||||
* greying without each consumer re-querying the cache index.
|
|
||||||
*/
|
|
||||||
fun observeAll(): Flow<List<PlaylistRef>> =
|
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||||
combine(
|
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||||
playlistDao.observeAll(),
|
|
||||||
playlistDao.observeCachedCounts(),
|
|
||||||
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
|
|
||||||
|
|
||||||
/** User-owned playlists only (systemVariant IS NULL). */
|
/** User-owned playlists only (systemVariant IS NULL). */
|
||||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||||
@@ -242,23 +233,6 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
|||||||
|
|
||||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
|
|
||||||
* from the cache-index counts. A playlist is fully cached when it has tracks
|
|
||||||
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
|
|
||||||
* of the Flow so the predicate is unit-testable without a Room database.
|
|
||||||
*/
|
|
||||||
internal fun mergePlaylistsWithCache(
|
|
||||||
rows: List<CachedPlaylistEntity>,
|
|
||||||
counts: List<PlaylistCachedCount>,
|
|
||||||
): List<PlaylistRef> {
|
|
||||||
val cachedById = counts.associate { it.playlistId to it.cachedCount }
|
|
||||||
return rows.map { row ->
|
|
||||||
val cached = cachedById[row.id] ?: 0
|
|
||||||
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||||
PlaylistRef(
|
PlaylistRef(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
+30
-85
@@ -28,17 +28,15 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -58,8 +56,6 @@ import com.composables.icons.lucide.Play
|
|||||||
import com.composables.icons.lucide.RefreshCw
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
@@ -76,7 +72,6 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -118,7 +113,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -145,15 +139,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||||
|
|
||||||
/**
|
|
||||||
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
|
||||||
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
|
||||||
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
|
||||||
* re-resolves the variant via [reloadRebuilt].
|
|
||||||
*/
|
|
||||||
private val staleInternal = MutableStateFlow(false)
|
|
||||||
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTrackIds()
|
likes.observeLikedTrackIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -169,14 +154,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.collect { handlePlaylistEvent(it) }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
|
||||||
// load while the server was unreachable re-fetches when health
|
|
||||||
// returns, instead of waiting for the Retry tap.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,12 +163,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
* ignored — the playlists-list screen handles those.
|
* ignored — the playlists-list screen handles those.
|
||||||
*/
|
*/
|
||||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||||
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
|
||||||
// user's system mixes at once) — handle it before the per-id filter.
|
|
||||||
if (event.kind == "playlist.system_rebuilt") {
|
|
||||||
markStaleIfSystem()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||||
if (eventPlaylistId != playlistId) return
|
if (eventPlaylistId != playlistId) return
|
||||||
when (event.kind) {
|
when (event.kind) {
|
||||||
@@ -200,14 +171,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun markStaleIfSystem() {
|
|
||||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
|
||||||
?.detail?.playlist ?: return
|
|
||||||
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
|
|
||||||
staleInternal.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLikeTrack(trackId: String) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -257,30 +220,6 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
|
|
||||||
* already ran server-side (uuid rotated) — re-resolve the variant to the
|
|
||||||
* fresh playlist id and hand it to [regenerated] for navigate-replace.
|
|
||||||
* Unlike [regenerate] this does NOT trigger another rebuild.
|
|
||||||
*/
|
|
||||||
fun reloadRebuilt() {
|
|
||||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
|
||||||
?.detail?.playlist ?: return
|
|
||||||
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
|
|
||||||
staleInternal.value = false
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching { repository.systemShuffle(variant).playlist.id }
|
|
||||||
.onSuccess { newId ->
|
|
||||||
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #980: user dismissed the stale-view snackbar without refreshing. */
|
|
||||||
fun dismissStale() {
|
|
||||||
staleInternal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Play the available tracks starting at [startTrackId] (or first available). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val refs = tracks.toPlayableTrackRefs()
|
val refs = tracks.toPlayableTrackRefs()
|
||||||
@@ -322,25 +261,8 @@ fun PlaylistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #980: a rebuild landed while this system-playlist screen is open. Offer a
|
|
||||||
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
|
|
||||||
// rotated uuid (via the regenerated navigate-replace flow).
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val stale by viewModel.stale.collectAsState()
|
|
||||||
LaunchedEffect(stale) {
|
|
||||||
if (stale) {
|
|
||||||
val result = snackbarHostState.showSnackbar(
|
|
||||||
message = "This mix was refreshed",
|
|
||||||
actionLabel = "Refresh",
|
|
||||||
duration = SnackbarDuration.Indefinite,
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
|
|
||||||
else viewModel.dismissStale()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(currentTitle(state)) },
|
title = { Text(currentTitle(state)) },
|
||||||
@@ -379,11 +301,7 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorRetry(
|
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||||
title = "Couldn't load playlist",
|
|
||||||
message = s.message,
|
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
@@ -667,6 +585,33 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
||||||
|
var retried by remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
if (!retried) {
|
||||||
|
retried = true
|
||||||
|
onRetry()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("Retry") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+18
-39
@@ -27,10 +27,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -42,7 +39,6 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -50,10 +46,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -72,7 +66,7 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
private val repository: PlaylistsRepository,
|
private val repository: PlaylistsRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
connectivity: ConnectivityObserver,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||||
@@ -80,18 +74,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from playlist tile play taps. */
|
/** Transient snackbar messages from playlist tile play taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||||
|
|
||||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||||
val offline: StateFlow<Boolean> = networkStatus.state
|
val offline: StateFlow<Boolean> = connectivity.online
|
||||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
.map { !it }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -101,18 +92,11 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
|
||||||
// health returns, so a load that failed offline heals unprompted.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refresh() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
refreshError.value = null
|
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -125,13 +109,12 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
combine(repository.observeAll(), refreshError) { list, err ->
|
repository.observeAll()
|
||||||
when {
|
.map { list ->
|
||||||
list.isNotEmpty() -> UiState.Success(list)
|
if (list.isEmpty()) {
|
||||||
// Empty cache + failed refresh is a load failure, not "no
|
UiState.Empty
|
||||||
// playlists yet" — surface Error so the user gets a Retry.
|
} else {
|
||||||
err != null -> UiState.Error(err)
|
UiState.Success(list)
|
||||||
else -> UiState.Empty
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
@@ -173,10 +156,9 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
@@ -209,15 +191,14 @@ private fun PlaylistsGrid(
|
|||||||
SectionHeader("System playlists")
|
SectionHeader("System playlists")
|
||||||
}
|
}
|
||||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||||
// Greyed offline when it needs the live server or isn't fully
|
|
||||||
// cached — dimmed but still tappable into the detail.
|
|
||||||
val greyed = offline && playlist.unavailableOffline
|
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClick = { onPlaylistClick(playlist.id) },
|
onClick = { onPlaylistClick(playlist.id) },
|
||||||
onPlay = { onPlay(playlist) },
|
onPlay = { onPlay(playlist) },
|
||||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||||
greyed = greyed,
|
// disable when offline. Empty mixes show a snackbar after tap.
|
||||||
|
playEnabled = playlist.trackCount > 0 &&
|
||||||
|
!(offline && playlist.refreshable),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,13 +207,11 @@ private fun PlaylistsGrid(
|
|||||||
SectionHeader("Your playlists")
|
SectionHeader("Your playlists")
|
||||||
}
|
}
|
||||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||||
val greyed = offline && playlist.unavailableOffline
|
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClick = { onPlaylistClick(playlist.id) },
|
onClick = { onPlaylistClick(playlist.id) },
|
||||||
onPlay = { onPlay(playlist) },
|
onPlay = { onPlay(playlist) },
|
||||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
playEnabled = playlist.trackCount > 0,
|
||||||
greyed = greyed,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -15,7 +15,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
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
|
||||||
@@ -41,10 +40,6 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||||
* uses it for system playlists in offline mode (their server-side
|
* uses it for system playlists in offline mode (their server-side
|
||||||
* shuffle endpoint is unreachable) and for empty playlists.
|
* shuffle endpoint is unreachable) and for empty playlists.
|
||||||
*
|
|
||||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
|
||||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
|
||||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistCard(
|
fun PlaylistCard(
|
||||||
@@ -53,13 +48,11 @@ fun PlaylistCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onPlay: (suspend () -> Unit)? = null,
|
onPlay: (suspend () -> Unit)? = null,
|
||||||
playEnabled: Boolean = true,
|
playEnabled: Boolean = true,
|
||||||
greyed: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val seedCache = LocalDetailSeedCache.current
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(176.dp)
|
.width(176.dp)
|
||||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
|
||||||
.clickable {
|
.clickable {
|
||||||
seedCache.stashPlaylist(playlist)
|
seedCache.stashPlaylist(playlist)
|
||||||
onClick()
|
onClick()
|
||||||
@@ -70,7 +63,7 @@ fun PlaylistCard(
|
|||||||
PlaylistCardCover(
|
PlaylistCardCover(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onPlay = onPlay,
|
onPlay = onPlay,
|
||||||
playEnabled = playEnabled && !greyed,
|
playEnabled = playEnabled,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -155,7 +148,6 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val PILL_BG_ALPHA = 0.85f
|
private const val PILL_BG_ALPHA = 0.85f
|
||||||
private const val GREYED_ALPHA = 0.45f
|
|
||||||
|
|
||||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -44,10 +43,9 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -26,7 +24,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -64,11 +61,6 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
|
||||||
// health returns, so a load that failed offline heals unprompted.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refresh() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -79,10 +78,9 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> EmptyState(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
message = s.message,
|
body = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+1
-62
@@ -3,17 +3,13 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
|
||||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -24,15 +20,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||||
|
|
||||||
// Cadence for in-flight request polling — matches the web client (#369):
|
|
||||||
// fast enough to feel live, slow enough not to hammer the server.
|
|
||||||
private const val POLL_INTERVAL_MS = 12_000L
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -40,21 +31,10 @@ class RequestsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// SSE push: reconciler completions arrive here; reload silently so the
|
|
||||||
// row updates in place rather than flashing the loading spinner.
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { silentReload() }
|
.collect { refresh() }
|
||||||
}
|
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
|
||||||
// Screen-level auto-recovery (issue #1245): a load that failed while
|
|
||||||
// the server was unreachable heals the moment health returns, without
|
|
||||||
// waiting for the next poll tick.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value is UiState.Error) silentReload()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,47 +54,6 @@ class RequestsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
|
||||||
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
|
||||||
* when nothing is in-flight — complements the SSE push so progress stays
|
|
||||||
* live even if the event stream drops.
|
|
||||||
*/
|
|
||||||
private suspend fun pollWhileInFlight() {
|
|
||||||
while (true) {
|
|
||||||
// Also retry from a failed load (issue #1245): from UiState.Error
|
|
||||||
// the in-flight predicate can never become true, so without this
|
|
||||||
// the poll never self-heals the screen — e.g. after a transient
|
|
||||||
// 500 that health monitoring (transport-level) never saw.
|
|
||||||
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
|
||||||
silentReload()
|
|
||||||
}
|
|
||||||
delay(POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasInFlightRequest(): Boolean {
|
|
||||||
val state = internal.value
|
|
||||||
return state is UiState.Success &&
|
|
||||||
state.data.any { it.status == RequestStatus.APPROVED }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reload the list in place without flipping to Loading — used by the poll
|
|
||||||
* loop and the SSE collector so visible rows update without a spinner
|
|
||||||
* flash. A transient failure keeps the current view; the next reload wins.
|
|
||||||
*/
|
|
||||||
private suspend fun silentReload() {
|
|
||||||
try {
|
|
||||||
val rows = repository.listMine()
|
|
||||||
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Keep the current view; the next poll / SSE / pull reconciles.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optimistically removes [id] from the success list so the row
|
* Optimistically removes [id] from the success list so the row
|
||||||
* disappears immediately; on server-side success the row is
|
* disappears immediately; on server-side success the row is
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import com.fabledsword.minstrel.api.endpoints.SearchApi
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||||
import com.fabledsword.minstrel.library.data.toDomain
|
import com.fabledsword.minstrel.library.data.toDomain
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -16,8 +16,8 @@ import javax.inject.Singleton
|
|||||||
private const val LOCAL_SEARCH_LIMIT = 20
|
private const val LOCAL_SEARCH_LIMIT = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
|
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
||||||
* Unstable the repository hits `/api/search` and returns the server's three-facet
|
* repository hits `/api/search` and returns the server's three-facet
|
||||||
* paged response. When health is Offline or ServerDown it falls back to
|
* paged response. When health is Offline or ServerDown it falls back to
|
||||||
* Room LIKE queries against `cached_*` so the user can still find
|
* Room LIKE queries against `cached_*` so the user can still find
|
||||||
* something to play from what's already on the device; the outcome's
|
* something to play from what's already on the device; the outcome's
|
||||||
@@ -27,7 +27,7 @@ private const val LOCAL_SEARCH_LIMIT = 20
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SearchRepository @Inject constructor(
|
class SearchRepository @Inject constructor(
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
private val serverHealth: NetworkStatusController,
|
private val serverHealth: ServerHealthController,
|
||||||
private val trackDao: CachedTrackDao,
|
private val trackDao: CachedTrackDao,
|
||||||
private val albumDao: CachedAlbumDao,
|
private val albumDao: CachedAlbumDao,
|
||||||
private val artistDao: CachedArtistDao,
|
private val artistDao: CachedArtistDao,
|
||||||
@@ -35,8 +35,7 @@ class SearchRepository @Inject constructor(
|
|||||||
private val api: SearchApi = retrofit.create()
|
private val api: SearchApi = retrofit.create()
|
||||||
|
|
||||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||||
ServerHealth.Healthy, ServerHealth.Unstable ->
|
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
|
||||||
SearchOutcome(remoteSearch(query), localOnly = false)
|
|
||||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||||
SearchOutcome(localSearch(query), localOnly = true)
|
SearchOutcome(localSearch(query), localOnly = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -114,7 +113,6 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
onRetry = viewModel::retry,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +151,11 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
onRetry: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
is SearchResultsState.Error -> ErrorRetry(
|
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||||
title = "Search failed",
|
|
||||||
message = state.message,
|
|
||||||
onRetry = onRetry,
|
|
||||||
)
|
|
||||||
is SearchResultsState.Loaded -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import com.fabledsword.minstrel.connectivity.recoveries
|
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -45,7 +43,6 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
networkStatus: NetworkStatusController,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -72,14 +69,6 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
runSearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Screen-level auto-recovery (issue #1245): a search that failed
|
|
||||||
// while the server was unreachable re-runs when health returns —
|
|
||||||
// the queryFlow pipeline can't, since the text hasn't changed.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect {
|
|
||||||
if (internal.value.results is SearchResultsState.Error) retry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -91,18 +80,6 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
setQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-run the current query after a failure. Bypasses the queryFlow
|
|
||||||
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
|
||||||
* without this, a transient error pins "Search failed" until the
|
|
||||||
* user edits the query.
|
|
||||||
*/
|
|
||||||
fun retry() {
|
|
||||||
val q = internal.value.query.trim()
|
|
||||||
if (q.isEmpty()) return
|
|
||||||
viewModelScope.launch { runSearch(q) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tapping a search result builds a queue from the full visible
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -95,7 +94,6 @@ fun SettingsScreen(
|
|||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,7 +116,6 @@ private fun SettingsList(
|
|||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
onToggleDiagnostics: (Boolean) -> Unit,
|
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -151,12 +148,6 @@ private fun SettingsList(
|
|||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
ListenBrainzCard()
|
ListenBrainzCard()
|
||||||
if (state.diagnosticsEnabledByAdmin) {
|
|
||||||
DiagnosticsCard(
|
|
||||||
optOut = state.diagnosticsOptOut,
|
|
||||||
onToggle = onToggleDiagnostics,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@@ -271,46 +262,6 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shown only while an admin has enabled debug mode on this account. Lets
|
|
||||||
// the user turn reporting OFF on this device (battery/privacy) without
|
|
||||||
// touching the account flag. Checked = reporting on = NOT opted out.
|
|
||||||
@Composable
|
|
||||||
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Diagnostics",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Send diagnostic reports",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your admin enabled debug mode to investigate an " +
|
|
||||||
"issue. This device streams connectivity and playback " +
|
|
||||||
"diagnostics. Turn off any time.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = !optOut,
|
|
||||||
onCheckedChange = { checked -> onToggle(!checked) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||||
|
|||||||
+2
-13
@@ -19,15 +19,12 @@ data class SettingsState(
|
|||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
val isSigningOut: Boolean = false,
|
val isSigningOut: Boolean = false,
|
||||||
val signedOut: Boolean = false,
|
val signedOut: Boolean = false,
|
||||||
// M9: account debug flag (admin-set) + this device's local opt-out.
|
|
||||||
val diagnosticsEnabledByAdmin: Boolean = false,
|
|
||||||
val diagnosticsOptOut: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
private val authStore: AuthStore,
|
authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -44,27 +41,19 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
authStore.baseUrl,
|
authStore.baseUrl,
|
||||||
authController.currentUser,
|
authController.currentUser,
|
||||||
authStore.diagnosticsOptOut,
|
|
||||||
transient,
|
transient,
|
||||||
) { url, user, optOut, t ->
|
) { url, user, t ->
|
||||||
SettingsState(
|
SettingsState(
|
||||||
serverUrl = url,
|
serverUrl = url,
|
||||||
username = user?.username.orEmpty(),
|
username = user?.username.orEmpty(),
|
||||||
isAdmin = user?.isAdmin == true,
|
isAdmin = user?.isAdmin == true,
|
||||||
isSigningOut = t.isSigningOut,
|
isSigningOut = t.isSigningOut,
|
||||||
signedOut = t.signedOut,
|
signedOut = t.signedOut,
|
||||||
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
|
||||||
diagnosticsOptOut = optOut,
|
|
||||||
)
|
)
|
||||||
}.collect { internal.value = it }
|
}.collect { internal.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-device opt-out of diagnostics while the account flag is on. */
|
|
||||||
fun setDiagnosticsOptOut(optOut: Boolean) {
|
|
||||||
authStore.setDiagnosticsOptOut(optOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun signOut() {
|
fun signOut() {
|
||||||
if (transient.value.isSigningOut) return
|
if (transient.value.isSigningOut) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -23,12 +23,8 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
||||||
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
* `Modifier.align(...)` inside it.
|
||||||
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
|
||||||
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
|
||||||
* the square's corner, outside the circle, and would otherwise be clipped
|
|
||||||
* away. Only the artwork + background are clipped to [shape].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -42,12 +38,8 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.size(size),
|
modifier = modifier
|
||||||
contentAlignment = Alignment.Center,
|
.size(size)
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(background),
|
.background(background),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -59,7 +51,6 @@ fun CoverTile(
|
|||||||
) {
|
) {
|
||||||
fallback()
|
fallback()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -24,26 +23,18 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
|||||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||||
*
|
|
||||||
* Renders inside a single-item LazyColumn (same rationale as
|
|
||||||
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
|
||||||
* under PullToRefreshBox — both the Retry button and a pull gesture
|
|
||||||
* can recover from the error state.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
title: String? = null,
|
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
|
||||||
item {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillParentMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(32.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -54,18 +45,9 @@ fun ErrorRetry(
|
|||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -81,6 +63,4 @@ fun ErrorRetry(
|
|||||||
Text(retryLabel)
|
Text(retryLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
|
|
||||||
* banner clears within seconds rather than waiting for the next poll — even on
|
|
||||||
* cache-only screens whose own refresh never touches the network. Backs
|
|
||||||
* [PullToRefreshScaffold].
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class PullRefreshNetworkViewModel @Inject constructor(
|
|
||||||
private val networkStatus: NetworkStatusController,
|
|
||||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
|
||||||
) : ViewModel() {
|
|
||||||
fun recheck() = networkStatus.recheck()
|
|
||||||
}
|
|
||||||
+1
-6
@@ -10,15 +10,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||||
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
|
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||||
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
|
|
||||||
* connection banner clears promptly on a deliberate user refresh.
|
|
||||||
*
|
*
|
||||||
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||||
* indicator hides exactly when the underlying coroutine completes,
|
* indicator hides exactly when the underlying coroutine completes,
|
||||||
@@ -35,7 +32,6 @@ import kotlinx.coroutines.launch
|
|||||||
fun PullToRefreshScaffold(
|
fun PullToRefreshScaffold(
|
||||||
onRefresh: suspend () -> Unit,
|
onRefresh: suspend () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -46,7 +42,6 @@ fun PullToRefreshScaffold(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
try {
|
try {
|
||||||
netVm.recheck() // deliberate pull → re-probe the server now
|
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.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.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank/unresolvable, while the image is still loading, and when the
|
* is blank or unresolvable.
|
||||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
|
||||||
* which the "You might like" row hits often).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -32,26 +22,12 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
fallback()
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
// Track Coil's load state so the fallback doubles as a placeholder
|
|
||||||
// (loading) and an error state (404 / unreachable) — not just a
|
|
||||||
// null-URL guard, which left present-but-failing URLs blank.
|
|
||||||
var state by remember(resolved) {
|
|
||||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
|
||||||
}
|
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = modifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
onState = { state = it },
|
|
||||||
)
|
)
|
||||||
if (state is AsyncImagePainter.State.Loading ||
|
|
||||||
state is AsyncImagePainter.State.Error
|
|
||||||
) {
|
|
||||||
fallback()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ fun TrackRow(
|
|||||||
trailing: @Composable RowScope.() -> Unit = {},
|
trailing: @Composable RowScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val health = LocalServerHealth.current
|
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
|
||||||
// Unstable is non-gating — only a real gating state dims uncached rows.
|
|
||||||
val offlineUnavailable =
|
|
||||||
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
|
|
||||||
trackId !in LocalCachedTrackIds.current
|
trackId !in LocalCachedTrackIds.current
|
||||||
val titleColor = if (nowPlaying) {
|
val titleColor = if (nowPlaying) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import retrofit2.http.GET
|
|||||||
/**
|
/**
|
||||||
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
||||||
* returning the server's running version + the minimum client version
|
* returning the server's running version + the minimum client version
|
||||||
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
|
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
|
||||||
* for both reachability and the VersionTooOld banner.
|
* to surface the VersionTooOld banner.
|
||||||
*/
|
*/
|
||||||
interface HealthzApi {
|
interface HealthzApi {
|
||||||
@GET("healthz")
|
@GET("healthz")
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
// Hysteresis on the reachable signal. Flipping internalReachable to false on
|
||||||
|
// the very first /healthz failure produced two false positives:
|
||||||
|
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
|
||||||
|
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
|
||||||
|
// ("http://localhost:8080") before the real server URL has hydrated.
|
||||||
|
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
|
||||||
|
// /healthz never reaches the handler, so the user sees a permanent
|
||||||
|
// "Server unreachable" banner even though all real /api/* calls succeed.
|
||||||
|
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
|
||||||
|
// the banner only fires on sustained, real unreachability — and a single
|
||||||
|
// success at any point resets the counter so transient hiccups self-clear.
|
||||||
|
private const val REACHABILITY_FAILURE_THRESHOLD = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the most recent /healthz version-compatibility check.
|
||||||
|
* `Skipped` means the server didn't include `min_client_version`
|
||||||
|
* (partial deploy or older server) and the UI should not gate.
|
||||||
|
*/
|
||||||
|
enum class VersionResult { OK, TOO_OLD, SKIPPED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls /healthz periodically and exposes the current
|
||||||
|
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
|
||||||
|
* on network errors — keeps the last-known result rather than
|
||||||
|
* showing a misleading "too old" on a connectivity blip.
|
||||||
|
*
|
||||||
|
* Constructed at app launch via the construct-the-singleton trick
|
||||||
|
* in [com.fabledsword.minstrel.MinstrelApplication].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class VersionCheckController @Inject constructor(
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow(VersionResult.SKIPPED)
|
||||||
|
val result: StateFlow<VersionResult> = internal.asStateFlow()
|
||||||
|
|
||||||
|
// Whether the most recent /healthz poll reached the server. Separate from
|
||||||
|
// VersionResult because "unreachable" and "version mismatch" drive
|
||||||
|
// different UX (offline banner vs version-too-old banner). Optimistic
|
||||||
|
// initial value -- the first poll fires within seconds of app launch and
|
||||||
|
// we don't want a "server down" flash before we've actually tried.
|
||||||
|
// Consumed by ServerHealthController to compose with ConnectivityObserver
|
||||||
|
// for the tri-state offline / server-down / healthy signal.
|
||||||
|
private val internalReachable = MutableStateFlow(true)
|
||||||
|
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||||
|
|
||||||
|
private var consecutiveFailures = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
runOnce()
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
|
||||||
|
fun recheck() {
|
||||||
|
scope.launch { runOnce() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runOnce() {
|
||||||
|
val outcome = runCatching { api.check() }
|
||||||
|
val response = outcome.getOrNull()
|
||||||
|
if (response == null) {
|
||||||
|
consecutiveFailures++
|
||||||
|
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
|
||||||
|
internalReachable.value
|
||||||
|
) {
|
||||||
|
Timber.w(
|
||||||
|
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
|
||||||
|
consecutiveFailures,
|
||||||
|
)
|
||||||
|
internalReachable.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!internalReachable.value) {
|
||||||
|
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0
|
||||||
|
internalReachable.value = true
|
||||||
|
val min = response.minClientVersion
|
||||||
|
internal.value = when {
|
||||||
|
min.isEmpty() -> VersionResult.SKIPPED
|
||||||
|
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||||
|
else -> VersionResult.OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user