Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
57d2299180 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
fa7ea41ccf | ||
|
|
e1e591b520 | ||
|
|
324059b2bd | ||
|
|
eec59193fa | ||
|
|
ca4832e620 | ||
|
|
cf0d37bf8e | ||
|
|
799dab029a | ||
|
|
7315e37c15 | ||
|
|
4f9b083eec | ||
|
|
f17356560d | ||
|
|
18a61f1065 | ||
|
|
6e39471a70 | ||
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
1138d75a45 | ||
|
|
cf7b489fec | ||
|
|
8483948f23 | ||
|
|
0cea82984c | ||
|
|
3acac985cd | ||
|
|
d3b40342b4 | ||
|
|
4f99b42844 | ||
|
|
5044e7a055 | ||
|
|
7d45a4e5c7 | ||
|
|
fa0827f668 | ||
|
|
52d53e0044 | ||
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d | ||
|
|
47de7be472 | ||
|
|
659554df0e | ||
|
|
2ecdd46a2b | ||
|
|
41fe76b90c | ||
|
|
6912dadf2b | ||
|
|
304e06acc8 | ||
|
|
235839b696 | ||
|
|
4f69c230c4 | ||
|
|
5749f48b4a | ||
|
|
f0c08e7326 | ||
|
|
199fec2058 | ||
|
|
65dd132b3d | ||
|
|
40384cc05e | ||
|
|
40056d2e9a | ||
|
|
2b3be8311a | ||
|
|
c30511e71b | ||
|
|
797ed1f5ad | ||
|
|
96c2eb6afb | ||
|
|
cce928e584 | ||
|
|
7d18a3c808 | ||
|
|
c34753f5b0 | ||
|
|
fbfd5550ff | ||
|
|
d6ee5a304d | ||
|
|
d497c57d6c | ||
|
|
2e92ba498c | ||
|
|
c1d143cf4a | ||
|
|
cb0af5efd3 | ||
|
|
20a76f4b39 | ||
|
|
7226dab9ff | ||
|
|
772a52b23e | ||
|
|
725ddca950 | ||
|
|
29e1e7c64c | ||
|
|
9ad4343c76 | ||
|
|
0d0a8f46b1 | ||
|
|
9e02878b61 | ||
|
|
48f288e2e5 | ||
|
|
2be07ef271 | ||
|
|
a670840114 | ||
|
|
5faa57634b | ||
|
|
fb4431207d | ||
|
|
60533073ad | ||
|
|
4b150a277e | ||
|
|
d145fee35d | ||
|
|
7628330f72 | ||
|
|
09102dc615 | ||
|
|
611715154b | ||
|
|
392454b249 | ||
|
|
cdfc79e6ab | ||
|
|
79f2d79a2e | ||
|
|
96b15b75e6 | ||
|
|
23a82fb38d | ||
|
|
782f152d37 | ||
|
|
bffa5b28bd | ||
|
|
8a58f07237 | ||
|
|
4d42e298dd | ||
|
|
4ed831d9c3 | ||
|
|
0de2437689 | ||
|
|
f4f4df7708 | ||
|
|
e932ab438c | ||
|
|
d05264ff80 | ||
|
|
a23e2e36ca | ||
|
|
5a80a1e460 | ||
|
|
16f76ea707 | ||
|
|
d4cc177db4 | ||
|
|
27766ae063 | ||
|
|
335d782215 | ||
|
|
2a8de82a17 | ||
|
|
096a3c0b15 | ||
|
|
a251dce7e3 | ||
|
|
97e0e88483 | ||
|
|
938dae7163 | ||
|
|
ad37937949 |
@@ -80,10 +80,16 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -131,12 +131,19 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
# error, not the default warn: image-release hard-depends on this
|
||||
# artifact existing, so an empty upload must fail here, not there.
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
shell: bash
|
||||
@@ -238,7 +245,20 @@ jobs:
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||
# the same one proven against this instance by the upload side.
|
||||
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
@@ -27,6 +27,7 @@ on:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'Makefile'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
@@ -53,6 +54,9 @@ jobs:
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
.PHONY: generate test test-short test-integration lint build
|
||||
.PHONY: generate generate-go verify-generate test test-short test-integration lint build
|
||||
|
||||
# renovate: datasource=docker depName=sqlc/sqlc
|
||||
SQLC_VERSION := 1.31.1
|
||||
|
||||
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||
generate:
|
||||
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
||||
|
||||
# Same codegen, run as a Go tool instead of a container. This is the CI path:
|
||||
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
|
||||
# SAME version as `generate` above so both routes emit identical output.
|
||||
generate-go:
|
||||
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
||||
|
||||
# Fail if the committed generated code no longer matches the .sql sources.
|
||||
#
|
||||
# Nothing verified this before, so internal/db/dbq could silently drift from
|
||||
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
|
||||
# without a regen would all pass CI while the typed layer lied about the SQL.
|
||||
#
|
||||
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
||||
# the log then contains sqlc's exact expected output, which is what you commit.
|
||||
verify-generate: generate-go
|
||||
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
|
||||
# diff`, which otherwise ignores untracked paths entirely — a whole
|
||||
# missing *.sql.go would sail through the check below.
|
||||
git add -N -- internal/db/dbq
|
||||
git --no-pager diff -- internal/db/dbq
|
||||
git diff --quiet -- internal/db/dbq
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
|
||||
@@ -4,16 +4,28 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
|
||||
> State and intelligence belong on the server, not the client.
|
||||
|
||||
<!-- TODO: screenshot of the home page -->
|
||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||
|
||||
## Highlights
|
||||
|
||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||
- **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 — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||
- **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).
|
||||
|
||||
## Scope and responsible use
|
||||
|
||||
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||
|
||||
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||
|
||||
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||
|
||||
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||
|
||||
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -24,10 +36,18 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
- minstrel-data:/data
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# 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:
|
||||
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
|
||||
depends_on: [db]
|
||||
|
||||
@@ -37,6 +57,8 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: 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:
|
||||
@@ -48,7 +70,29 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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).
|
||||
## First run
|
||||
|
||||
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).
|
||||
|
||||
@@ -57,7 +101,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:
|
||||
|
||||
- `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` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `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_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.
|
||||
@@ -66,8 +110,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
## Updating
|
||||
|
||||
- `: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.
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `: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
|
||||
|
||||
|
||||
@@ -210,4 +210,17 @@ dependencies {
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
tasks.withType<Test> { useJUnitPlatform() }
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
// Print the assertion message + full stack trace for failures. The
|
||||
// default console output gives only "AssertionError at Foo.kt:12", and
|
||||
// for a failure inside a `runTest { }` lambda even that line collapses
|
||||
// to the test function's own line (the assertion frames live in the
|
||||
// suspend-lambda class, which Gradle filters out) — leaving nothing to
|
||||
// debug from when the HTML report isn't reachable, as in CI.
|
||||
testLogging {
|
||||
events("failed")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
grants the silent path only when the installer opts in via
|
||||
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
|
||||
installed app targets API 29+, the installer holds this permission, and
|
||||
the target is the installer itself — all true here, since Minstrel is
|
||||
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
@@ -19,9 +28,9 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Minstrel"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
@@ -48,15 +57,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||
else in the app ever used that authority. -->
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
|
||||
@@ -6,10 +6,13 @@ import androidx.work.Configuration
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
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.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
@@ -27,6 +30,10 @@ import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
|
||||
// already-loaded art still appears instantly — only a genuine fetch fades.
|
||||
private const val ART_CROSSFADE_MS = 220
|
||||
|
||||
@HiltAndroidApp
|
||||
class MinstrelApplication :
|
||||
Application(),
|
||||
@@ -155,6 +162,11 @@ class MinstrelApplication :
|
||||
*/
|
||||
@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
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -206,11 +218,18 @@ class MinstrelApplication :
|
||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||
* before Coil makes its first request.
|
||||
*
|
||||
* Crossfade is set here rather than per-call so every cover surface
|
||||
* in the app fades its artwork in instead of snapping it. Art
|
||||
* landing a beat after its tile was the most visible pop-in on Home
|
||||
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||
* same window so the two read as one cross-dissolve.
|
||||
*/
|
||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||
}
|
||||
.crossfade(ART_CROSSFADE_MS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
||||
@HiltViewModel
|
||||
class AdminInvitesViewModel @Inject constructor(
|
||||
private val repository: AdminInvitesRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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() {
|
||||
|
||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.TriangleAlert
|
||||
import com.composables.icons.lucide.Users
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
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.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||
|
||||
sealed interface AdminLandingUiState {
|
||||
data object Loading : AdminLandingUiState
|
||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
||||
private val requestsRepo: AdminRequestsRepository,
|
||||
private val quarantineRepo: AdminQuarantineRepository,
|
||||
private val usersRepo: AdminUsersRepository,
|
||||
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
||||
val req = async { requestsRepo.list().size }
|
||||
val qua = async { quarantineRepo.list().size }
|
||||
val usr = async { usersRepo.list().size }
|
||||
AdminCounts(req.await(), qua.await(), usr.await())
|
||||
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||
}
|
||||
internal.value = AdminLandingUiState.Success(counts)
|
||||
} catch (
|
||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
||||
onClick = { navController.navigate(AdminUsers) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.Music,
|
||||
title = "Tag sources",
|
||||
subtitle = "Metadata enrichment providers",
|
||||
count = counts.tagSources,
|
||||
onClick = { navController.navigate(AdminTagSources) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
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
|
||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||
"duplicates, their reports get aggregated and surfaced here.",
|
||||
)
|
||||
is AdminQuarantineUiState.Error -> EmptyState(
|
||||
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load queue",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminQuarantineUiState.Success -> QueueList(
|
||||
rows = s.rows,
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
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.models.AdminQuarantineItemRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
||||
class AdminQuarantineViewModel @Inject constructor(
|
||||
private val repository: AdminQuarantineRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
.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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
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
|
||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
||||
body = "When users ask Lidarr for new music, their pending " +
|
||||
"requests show up here for approval.",
|
||||
)
|
||||
is AdminRequestsUiState.Error -> EmptyState(
|
||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load requests",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminRequestsUiState.Success -> RequestList(
|
||||
rows = s.rows,
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
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.models.RequestRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
||||
private val repository: AdminRequestsRepository,
|
||||
private val usersRepository: AdminUsersRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.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 {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
||||
@HiltViewModel
|
||||
class AdminUsersViewModel @Inject constructor(
|
||||
private val repository: AdminUsersRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
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,
|
||||
)
|
||||
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
||||
|
||||
@POST("api/requests")
|
||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||
|
||||
/**
|
||||
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||
* server-side (90 days) and never fed into the taste profile.
|
||||
*
|
||||
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||
* the server has no local row to resolve a display name from and returns
|
||||
* 400 without it.
|
||||
*/
|
||||
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun snoozeSuggestion(
|
||||
@Path("mbid") mbid: String,
|
||||
@Body body: SnoozeSuggestionBody,
|
||||
)
|
||||
|
||||
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||
|
||||
/**
|
||||
* Currently-parked suggestions. Server filters expired rows, so every
|
||||
* row returned is still snoozed. This is the only route back to an
|
||||
* un-snooze once the card has left the deck.
|
||||
*/
|
||||
@GET("api/discover/snoozes")
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UserRef
|
||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||
@@ -35,6 +36,7 @@ class AuthController @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: AuthApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
|
||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||
@@ -55,6 +57,10 @@ class AuthController @Inject constructor(
|
||||
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() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,9 +78,32 @@ class AuthController @Inject constructor(
|
||||
)
|
||||
currentUserState.value = 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `/api/auth/logout` so the server can drop its session row.
|
||||
|
||||
@@ -59,6 +59,9 @@ class AuthStore @Inject constructor(
|
||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
init {
|
||||
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
|
||||
themeModeState.value = row?.themeMode
|
||||
clientIdState.value = row?.clientId
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,11 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistCacheSettings(encoded) }
|
||||
}
|
||||
|
||||
fun setDiagnosticsOptOut(value: Boolean) {
|
||||
diagnosticsOptOutState.value = value
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
private suspend fun persistCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
@@ -160,6 +169,14 @@ 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(
|
||||
id = ROW_ID,
|
||||
sessionCookie = sessionCookieState.value,
|
||||
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
|
||||
CacheSettings.serializer(),
|
||||
cacheSettingsState.value,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
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.entities.AudioCacheIndexEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
@@ -30,6 +31,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
|
||||
/**
|
||||
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedHomeIndexEntity::class,
|
||||
CachedHistorySnapshotEntity::class,
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
version = 6,
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
||||
version = 7,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
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.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
@@ -105,5 +106,10 @@ object DatabaseModule {
|
||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||
db.audioCacheIndexDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
private const val DATABASE_NAME = "minstrel.db"
|
||||
}
|
||||
|
||||
@@ -42,4 +42,8 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the serialized cache settings. */
|
||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
||||
)
|
||||
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
||||
|
||||
/** True when Home has any cached section rows to render. */
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
|
||||
suspend fun hasAny(): Boolean
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||
|
||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
||||
suspend fun deleteBySection(section: String)
|
||||
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||
suspend fun deleteSections(sections: List<String>)
|
||||
|
||||
/**
|
||||
* Swaps every listed section's rows in ONE transaction.
|
||||
*
|
||||
* Atomicity is the point, not just tidiness: Room's
|
||||
* InvalidationTracker only notifies observers after the transaction
|
||||
* commits, so [observeBySection] never sees the empty gap between the
|
||||
* delete and the re-insert. Replacing sections one at a time (and
|
||||
* un-transacted) made each Home row emit `emptyList()` — visibly
|
||||
* collapsing — before refilling, and made the seven sections do it in
|
||||
* sequence rather than as a single content swap.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
|
||||
deleteSections(sections)
|
||||
if (rows.isNotEmpty()) upsertAll(rows)
|
||||
}
|
||||
|
||||
@Query("DELETE FROM cached_home_index")
|
||||
suspend fun clear()
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun maxPosition(playlistId: String): Int?
|
||||
|
||||
/** Replace-all pattern for a playlist; called after a sync delta lands. */
|
||||
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun deleteByPlaylist(playlistId: String)
|
||||
|
||||
/**
|
||||
* Replaces a playlist's whole membership in ONE transaction; called
|
||||
* after a refresh or a sync delta lands.
|
||||
*
|
||||
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||
* gap is a real observed state — it's what made every Home row visibly
|
||||
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||
* write in `CachedHomeIndexDao`.
|
||||
*
|
||||
* Nothing observes [observeByPlaylist] live today, so this is
|
||||
* pre-emptive: it means making playlist detail cache-first later can't
|
||||
* silently reintroduce that flicker.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replacePlaylistTracks(
|
||||
playlistId: String,
|
||||
rows: List<CachedPlaylistTrackEntity>,
|
||||
) {
|
||||
deleteByPlaylist(playlistId)
|
||||
if (rows.isNotEmpty()) upsertAll(rows)
|
||||
}
|
||||
|
||||
@Query(
|
||||
"DELETE FROM cached_playlist_tracks " +
|
||||
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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()
|
||||
}
|
||||
@@ -36,4 +36,11 @@ data class AuthSessionEntity(
|
||||
* CacheSettings shape evolves.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
)
|
||||
@@ -35,6 +35,12 @@ object MutationKind {
|
||||
// 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"
|
||||
|
||||
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||
* is the TARGET state, so repeated taps collapse to one replay.
|
||||
*
|
||||
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||
* so a single payload shape serves both directions.
|
||||
*/
|
||||
suspend fun enqueueSuggestionSnoozeToggle(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||
json.encodeToString(
|
||||
SuggestionSnoozeTogglePayload.serializer(),
|
||||
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||
* the replayer can collapse repeated toggles for one candidate down to the
|
||||
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||
* extends the window, and un-snoozing something already back is a 404 the
|
||||
* replayer treats as permanent (nothing left to do).
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeTogglePayload(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val desiredSnoozed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
|
||||
val atIso: String,
|
||||
val durationPlayedMs: Long,
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
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)
|
||||
// Collapse superseded toggles (likes, suggestion snoozes): 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 state — a snooze the user already undid would come back.
|
||||
val superseded = supersededToggleIds(rows, json)
|
||||
for (row in rows) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
deviceClass = decoded.deviceClass,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||
*
|
||||
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||
* already holds, so there is nothing left to send.
|
||||
*/
|
||||
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||
if (decoded.desiredSnoozed) {
|
||||
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||
} else {
|
||||
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||
* `CachedMutationDao.getAll()` returns.
|
||||
*/
|
||||
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||
.forEach { (key, id) ->
|
||||
// Ascending ids mean a prior entry for this key is always older.
|
||||
latestByEntity.put(key, id)?.let(superseded::add)
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||
* good one behind it; the dispatcher DROPs it on its own.
|
||||
*
|
||||
* The kind is part of the key so two toggle kinds can never collide on the
|
||||
* same entity id.
|
||||
*/
|
||||
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.cache.sync
|
||||
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||
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.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
||||
// 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 {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||
suspend fun syncSafe() {
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
runCatching { sync() }
|
||||
.onSuccess { lastSyncErrorInternal.value = null }
|
||||
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||
* to play music that would have played fine.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
) : DefaultLifecycleObserver {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* App returned to the foreground — probe now rather than waiting for the
|
||||
* poll loop (#1209).
|
||||
*
|
||||
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
|
||||
* a user bringing the app up is exactly when a stale banner and a refused
|
||||
* track are most visible, and it's a once-per-foreground cost.
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
recheck()
|
||||
}
|
||||
|
||||
private suspend fun reduceLoop() {
|
||||
for (intent in intents) {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||
* (#1209).
|
||||
*
|
||||
* A link handoff fails every in-flight request at once, so a burst is one
|
||||
* event producing N failures — not N independent observations that the server
|
||||
* is gone. Without this, two simultaneous failures corroborated each other
|
||||
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||
* every uncached track. The app declined to play music that would have played
|
||||
* fine, for a blip that had already resolved.
|
||||
*
|
||||
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||
* still letting a genuine outage corroborate within seconds once a client
|
||||
* retries. The sustained-time backstop covers the case where nothing retries
|
||||
* at all — and if nothing is asking, a late ServerDown costs nothing.
|
||||
*/
|
||||
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
/**
|
||||
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||
*
|
||||
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||
* recorded one are dropped rather than stacked: see that constant for why
|
||||
* a burst must not corroborate itself.
|
||||
*/
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
val last = recentOpFailures.lastOrNull()
|
||||
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,328 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||
api.listSuggestions().map { it.toDomain() }
|
||||
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||
api.listSnoozes().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||
* transport failure the target state is queued for the replayer rather
|
||||
* than dropped.
|
||||
*
|
||||
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||
* meaningful failed state to show — the user asked for a card to go away,
|
||||
* and it will, either now or when the queue drains.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = true,
|
||||
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||
|
||||
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = false,
|
||||
) { api.unsnoozeSuggestion(mbid) }
|
||||
|
||||
private suspend fun toggleSnooze(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
call: suspend () -> Unit,
|
||||
) {
|
||||
try {
|
||||
call()
|
||||
} catch (e: HttpException) {
|
||||
// A 4xx is the server's considered answer, not a lost call, so
|
||||
// queueing it would be wrong twice over: the replay is guaranteed
|
||||
// to fail again, and the enqueue would raise a "will sync when
|
||||
// online" snackbar for something already settled. The common case
|
||||
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||
// the end state the user wanted anyway.
|
||||
if (!isPermanent(e.code())) {
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Transport failure — intentional swallow, same offline-first
|
||||
// rationale as createRequest above. The queue carries the desired
|
||||
// STATE, so a later undo supersedes this rather than fighting it
|
||||
// on replay.
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||
* and the drop decision there can't disagree: 4xx is 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
|
||||
|
||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||
|
||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
||||
RequestOutcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mappers (internal — wire types stay out of UI) ──
|
||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
attribution = attribution.map { it.toDomain() },
|
||||
matchedTags = matchedTags,
|
||||
)
|
||||
|
||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
||||
isLiked = isLiked,
|
||||
)
|
||||
|
||||
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
snoozedUntil = snoozedUntil,
|
||||
)
|
||||
|
||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||
kind = kind,
|
||||
artistMbid = artistMbid,
|
||||
|
||||
@@ -40,7 +40,9 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
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.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -103,12 +105,25 @@ private fun DiscoverBody(
|
||||
ResultsState.Idle -> SuggestionsPane(
|
||||
state = state.suggestions,
|
||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||
snoozeUi = SnoozeUi(
|
||||
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||
snoozes = state.snoozes,
|
||||
// No snackbar on snooze: the row itself flips to "Not
|
||||
// right now" with an Undo, so a snackbar would only
|
||||
// repeat what the user can already see — and cover the
|
||||
// next row while doing it.
|
||||
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||
onUnsnooze = { mbid, name ->
|
||||
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||
},
|
||||
),
|
||||
onRequest = { s ->
|
||||
scope.launch {
|
||||
val outcome = viewModel.requestSuggestion(s)
|
||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||
}
|
||||
},
|
||||
onRetry = { viewModel.loadSuggestions() },
|
||||
)
|
||||
ResultsState.Loading -> LoadingCentered()
|
||||
is ResultsState.Loaded -> ResultsList(
|
||||
@@ -121,7 +136,11 @@ private fun DiscoverBody(
|
||||
}
|
||||
},
|
||||
)
|
||||
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
||||
is ResultsState.Error -> ErrorRetry(
|
||||
title = "Search failed",
|
||||
message = r.message,
|
||||
onRetry = viewModel::runSearch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,17 +191,36 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||
* through as four more parameters — the pane grew from one action to three
|
||||
* with slice 4 and the signatures stopped being readable.
|
||||
*/
|
||||
private data class SnoozeUi(
|
||||
val locallySnoozedMbids: Set<String>,
|
||||
val snoozes: List<SuggestionSnoozeRef>,
|
||||
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||
val onUnsnooze: (String, String) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SuggestionsPane(
|
||||
state: SuggestionState,
|
||||
locallyRequestedMbids: Set<String>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
SuggestionState.Loading -> LoadingCentered()
|
||||
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
||||
is SuggestionState.Error -> ErrorRetry(
|
||||
title = "Couldn't load suggestions",
|
||||
message = state.message,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
is SuggestionState.Loaded -> SuggestionsList(
|
||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||
snoozeUi = snoozeUi,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
}
|
||||
@@ -191,6 +229,7 @@ private fun SuggestionsPane(
|
||||
@Composable
|
||||
private fun SuggestionsList(
|
||||
items: List<ArtistSuggestionRef>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -199,13 +238,61 @@ private fun SuggestionsList(
|
||||
) {
|
||||
item { SuggestionsHeader() }
|
||||
if (items.isEmpty()) {
|
||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
||||
// An empty deck used to mean one thing — no listening signal yet.
|
||||
// With snoozing it can also mean "you parked them all", and telling
|
||||
// that user to go listen to something would be wrong advice.
|
||||
item {
|
||||
CenteredMessage(
|
||||
if (snoozeUi.snoozes.isEmpty()) {
|
||||
"Listen to or like an artist to fill this in."
|
||||
} else {
|
||||
"Nothing new right now — the artists you've parked are below."
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(items = items, key = { it.mbid }) { s ->
|
||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
||||
SuggestionTile(
|
||||
s = s,
|
||||
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||
onRequest = { onRequest(s) },
|
||||
onSnooze = { snoozeUi.onSnooze(s) },
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||
// separate screen: it's a short list the user rarely needs, but it must
|
||||
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||
// this is the only way back to it.
|
||||
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||
item { SnoozedHeader() }
|
||||
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||
SnoozedTile(
|
||||
row = row,
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SnoozedHeader() {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||
Text(
|
||||
text = "Not right now",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Disc3
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
|
||||
@Composable
|
||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
internal fun SuggestionTile(
|
||||
s: ArtistSuggestionRef,
|
||||
snoozed: Boolean,
|
||||
onRequest: () -> Unit,
|
||||
onSnooze: () -> Unit,
|
||||
onUnsnooze: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (s.attributionText.isNotEmpty()) {
|
||||
// Once parked, the "because you liked X" line is no longer the
|
||||
// useful thing to say — confirming what just happened is.
|
||||
val secondary =
|
||||
if (snoozed) "Not right now — hidden for a while" else s.reasonText
|
||||
if (secondary.isNotEmpty()) {
|
||||
Text(
|
||||
text = s.attributionText,
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
if (snoozed) {
|
||||
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||
} else {
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
IconButton(onClick = onSnooze) {
|
||||
Icon(
|
||||
imageVector = Lucide.Clock,
|
||||
// Rule #101: the label states what happens, and passes no
|
||||
// judgement on the music. Never "not for me".
|
||||
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the parked list. This exists because a snoozed candidate is by
|
||||
* definition absent from the deck above, so without it there is no route back
|
||||
* to an un-snooze once the card has gone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Back ${row.returnsIn()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.fabledsword.minstrel.discover.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -25,6 +28,17 @@ data class DiscoverState(
|
||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||
val results: ResultsState = ResultsState.Idle,
|
||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||
/**
|
||||
* Parked candidates, for the manage list under the feed. Empty is the
|
||||
* normal case and hides the section entirely.
|
||||
*/
|
||||
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||
/**
|
||||
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||
* than yanking it out from under the user's finger; the row is gone on the
|
||||
* next load, and [snoozes] is the way back after that.
|
||||
*/
|
||||
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
sealed interface SuggestionState {
|
||||
@@ -46,6 +60,7 @@ sealed interface ResultsState {
|
||||
@HiltViewModel
|
||||
class DiscoverViewModel @Inject constructor(
|
||||
private val repository: DiscoverRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(DiscoverState())
|
||||
@@ -53,6 +68,15 @@ class DiscoverViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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) {
|
||||
@@ -84,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Refresh the parked list alongside the deck: a snooze made on another
|
||||
// client should show up here, and one whose window lapsed should drop
|
||||
// off. Sequenced after the deck load rather than raced with it so the
|
||||
// two panes can't disagree about a candidate mid-refresh.
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the parked list. Failure is deliberately silent: this is a
|
||||
* secondary pane, and an error banner for it would sit above the suggestion
|
||||
* feed the user actually came for. The list stays as-is and the next
|
||||
* refresh retries.
|
||||
*/
|
||||
private suspend fun loadSnoozes() {
|
||||
try {
|
||||
val rows = repository.listSnoozes()
|
||||
internal.update { it.copy(snoozes = rows) }
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep whatever we last showed rather than blanking the section.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||
* immediately; the repository handles the offline case, so there is no
|
||||
* failure branch to revert here — unlike the web client, where the fetch
|
||||
* either lands or doesn't.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||
repository.snoozeSuggestion(s.mbid, s.name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/** Brings a parked suggestion back, from either the card or the list. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||
repository.unsnoozeSuggestion(mbid, name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
fun runSearch() {
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.HistoryRepository
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
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.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
class HistoryTabViewModel @Inject constructor(
|
||||
private val repository: HistoryRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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 {
|
||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
||||
title = "No listening history yet",
|
||||
body = "Play something — your recent plays will show up here.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load history",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> HistoryList(
|
||||
entries = s.data,
|
||||
|
||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||
* per-item tile providers.
|
||||
*
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||
* one transaction (so the rows update together in a single emission
|
||||
* rather than collapsing and refilling), and pre-warms the top artists
|
||||
* via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// Per-section observe accessors (one per Home row) inflate the function
|
||||
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/** True when the index cache already has content on screen to protect. */
|
||||
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
* index change; missing entity rows hydrate via the on-miss path.
|
||||
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||
* a single transaction, then pre-warms the top artists. Missing
|
||||
* entity rows hydrate via the on-miss path.
|
||||
*
|
||||
* One transaction for all seven sections is deliberate: Room notifies
|
||||
* observers once, on commit, so Home swaps from the old content to
|
||||
* the new in a single emission. Per-section, un-transacted writes
|
||||
* made every row visibly collapse to empty and refill, one after
|
||||
* another (issue #2327).
|
||||
*/
|
||||
suspend fun refreshIndex() {
|
||||
val wire = api.getHomeIndex()
|
||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
homeIndexDao.replaceSections(
|
||||
sections = ALL_SECTIONS,
|
||||
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
|
||||
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
|
||||
rowsFor(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>) {
|
||||
homeIndexDao.deleteBySection(section)
|
||||
if (ids.isEmpty()) return
|
||||
homeIndexDao.upsertAll(
|
||||
ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
entityId = id,
|
||||
)
|
||||
},
|
||||
private fun rowsFor(
|
||||
section: String,
|
||||
entityType: String,
|
||||
ids: List<String>,
|
||||
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
entityId = id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The section's ordered entity ids, deduplicated.
|
||||
*
|
||||
* Room re-runs the query on every write to `cached_home_index` — and
|
||||
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||
* comparing whole rows would call every rewrite a change. Comparing
|
||||
* the id list instead means a section whose contents didn't actually
|
||||
* move never restarts the `flatMapLatest` below, which would
|
||||
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||
* and flicker unchanged tiles (issue #2327).
|
||||
*/
|
||||
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||
homeIndexDao.observeBySection(section)
|
||||
.map { rows -> rows.map { it.entityId } }
|
||||
.distinctUntilChanged()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
|
||||
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"
|
||||
|
||||
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
|
||||
val ALL_SECTIONS = listOf(
|
||||
SECTION_RECENTLY_ADDED_ALBUMS,
|
||||
SECTION_REDISCOVER_ALBUMS,
|
||||
SECTION_REDISCOVER_ARTISTS,
|
||||
SECTION_MOST_PLAYED_TRACKS,
|
||||
SECTION_LAST_PLAYED_ARTISTS,
|
||||
SECTION_YOU_MIGHT_LIKE_ALBUMS,
|
||||
SECTION_YOU_MIGHT_LIKE_ARTISTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -55,6 +65,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
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.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -75,27 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.UpdateVeilController
|
||||
import com.fabledsword.minstrel.shared.VeilOutcome
|
||||
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||
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.LocalArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
@@ -107,6 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||
|
||||
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||
// appears at all — only when a refresh actually changes something — and how
|
||||
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||
// which lowered it while tiles and artwork were still landing (#2327).
|
||||
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||
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
|
||||
|
||||
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||
// two never both vanish for a frame mid-handoff.
|
||||
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class HomeSections(
|
||||
@@ -139,6 +177,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -160,14 +199,29 @@ class HomeViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
|
||||
|
||||
/** Transient snackbar messages from offline-pool taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
/**
|
||||
* Transient snackbar messages: offline-pool taps, playback failures, and
|
||||
* the outcome of a refresh the user explicitly asked for.
|
||||
*/
|
||||
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||
* arriving a beat after its tile lands behind the veil rather than
|
||||
* popping in on screen.
|
||||
*/
|
||||
val artTracker = ArtSettleTracker()
|
||||
|
||||
/**
|
||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||
@@ -180,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||
}.shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||
}
|
||||
@@ -217,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||
if (detail.tracks.isEmpty()) {
|
||||
poolMessages.trySend("This album has no tracks to play.")
|
||||
snackbarMessages.trySend("This album has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -242,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("This artist has no tracks to play.")
|
||||
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -267,26 +321,67 @@ class HomeViewModel @Inject constructor(
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||
poolMessages.trySend(it)
|
||||
snackbarMessages.trySend(it)
|
||||
}
|
||||
}.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls both /home/index and the playlists list. Returns the Job
|
||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
||||
* actual completion before hiding the indicator.
|
||||
* Pulls /home/index, the playlists list and the system-playlist
|
||||
* status. Returns true when the load-bearing /home/index pull
|
||||
* succeeded — the veil controller retries on false and reports the
|
||||
* outcome, so this must report failure rather than swallow it.
|
||||
*/
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
||||
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||
// /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.
|
||||
//
|
||||
// Cleared on success, NOT at the start of each attempt: with the
|
||||
// veil's retries, clearing up front made a failing cold start
|
||||
// flash the "Welcome to Minstrel" empty state (empty cache + no
|
||||
// error reads as Empty) between one attempt and the next.
|
||||
val home = async {
|
||||
runCatching { homeRepository.refreshIndex() }
|
||||
.onSuccess { refreshError.value = null }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
.isSuccess
|
||||
}
|
||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||
val status = launch {
|
||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||
.onSuccess { systemStatusInternal.value = it }
|
||||
}
|
||||
home.join()
|
||||
lists.join()
|
||||
status.join()
|
||||
home.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||
* the controller's retries and reports its outcome; over an empty cache
|
||||
* there's no content to protect, so no veil goes up.
|
||||
*/
|
||||
fun retry() = veil.request(userInitiated = true)
|
||||
|
||||
/**
|
||||
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||
* the automatic paths, and a small spinner didn't hide it).
|
||||
*
|
||||
* Suspends until the veil has taken over OR the session has finished,
|
||||
* so the pull indicator hands off to exactly one successor: the veil if
|
||||
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||
* timeout is only a backstop against a session that outlives it.
|
||||
*/
|
||||
suspend fun refreshFromPull() {
|
||||
val before = veil.finishedSessions.value
|
||||
veil.request(userInitiated = true)
|
||||
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||
veiled || finished != before
|
||||
}.first { it }
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<HomeSections>> =
|
||||
@@ -325,10 +420,136 @@ class HomeViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||
combine(
|
||||
observeHomeSections(),
|
||||
playlistsRepository.observeAll(),
|
||||
refreshError,
|
||||
) { sections, playlists, err ->
|
||||
val merged = sections.copy(playlists = playlists)
|
||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
||||
when {
|
||||
!merged.isAllEmpty -> UiState.Success(merged)
|
||||
err != null -> UiState.Error(err)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Updating veil ───────────────────────────────────────────────
|
||||
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||
// property initialisers and init blocks in declaration order.
|
||||
|
||||
/**
|
||||
* What the veil watches to decide Home has stopped moving: the whole
|
||||
* rendered state, plus how many covers are still loading.
|
||||
*
|
||||
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||
* album — changes this value and re-arms the veil's quiet window.
|
||||
*
|
||||
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||
* ([MetadataProvider] swallows those errors), so treating "no
|
||||
* skeletons left" as the settle condition would pin the veil to its
|
||||
* hard ceiling on every refresh. They're covered by the content key
|
||||
* instead: each tile that lands re-arms the window, and once they stop
|
||||
* landing the screen is genuinely still.
|
||||
*/
|
||||
private val settleSignal: Flow<VeilSettleState> =
|
||||
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||
VeilSettleState(
|
||||
contentKey = state,
|
||||
hasContent = state is UiState.Success,
|
||||
quiescent = artInFlight == 0,
|
||||
)
|
||||
}
|
||||
|
||||
private val veil = UpdateVeilController(
|
||||
scope = viewModelScope,
|
||||
settleSignal = settleSignal,
|
||||
shouldVeil = {
|
||||
// Only worth hiding churn when there's already content to
|
||||
// hide. A cold load over an empty cache keeps its skeleton —
|
||||
// veiling that would replace a useful affordance with an
|
||||
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||
// still reads Loading until the screen subscribes, so on a
|
||||
// process restore over a warm cache it would say "no content"
|
||||
// right before the cache emits.
|
||||
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||
},
|
||||
onSessionEnd = ::reportRefreshOutcome,
|
||||
work = ::runRefresh,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tells the user how a refresh *they asked for* went, in the one case
|
||||
* the veil can't: when nothing changed there's no veil to see, and a
|
||||
* pull that produces no visible response at all reads as broken.
|
||||
*
|
||||
* Only user-initiated sessions say anything. The same outcome from a
|
||||
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||
* is noise, and "Already up to date" on every launch would be worse
|
||||
* than silence (operator's call, 2026-07-31).
|
||||
*/
|
||||
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||
if (!result.userInitiated) return
|
||||
when (result.outcome) {
|
||||
// The veil was the feedback.
|
||||
VeilOutcome.CHANGED -> return
|
||||
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the "Updating your mixes…" veil should be raised.
|
||||
*
|
||||
* Raised only when a refresh actually changes what's on screen, and then
|
||||
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||
* for a fixed delay after the network pull returns (issue #2327). A
|
||||
* refresh that returns what's already cached shows no veil at all;
|
||||
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||
*/
|
||||
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||
|
||||
init {
|
||||
// Every refresh path goes through the controller, which decides
|
||||
// per session whether to raise the veil. That includes the initial
|
||||
// load: over a warm cache it's a full re-pull that churns every
|
||||
// section, and it used to run completely unveiled.
|
||||
veil.request()
|
||||
// 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.
|
||||
// This is also the recovery that keeps trying after the veil has
|
||||
// given up and lowered; the controller sets no latch against it.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { veil.request() }
|
||||
}
|
||||
// Server-side changes that rewrite what Home renders (#968 and
|
||||
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||
// web SSE consumer.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||
.collect { veil.request() }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||
* Recently added (and Home never reacted to it at all before).
|
||||
*/
|
||||
private val VEILED_EVENT_KINDS = setOf(
|
||||
"playlist.system_rebuilt",
|
||||
"playlist.created",
|
||||
"playlist.updated",
|
||||
"playlist.deleted",
|
||||
"playlist.tracks_changed",
|
||||
"scan.run_finished",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Screen ──────────────────────────────────────────────────────────
|
||||
@@ -356,46 +577,114 @@ fun HomeScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
onRefresh = { viewModel.refreshFromPull() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
// Key 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.
|
||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> HomeSkeletonContent()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "Welcome to Minstrel",
|
||||
body = "Nothing to show yet — scan a folder in your server " +
|
||||
"settings, then come back here for system playlists " +
|
||||
"and recommendations.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
title = "Couldn't load home",
|
||||
body = s.message,
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
sections = s.data,
|
||||
systemStatus = systemStatus,
|
||||
offline = offline,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||
onMostPlayedTap = viewModel::playMostPlayed,
|
||||
onPlayPool = viewModel::playPool,
|
||||
onPlayAlbum = viewModel::playAlbum,
|
||||
onPlayArtist = viewModel::playArtistShuffled,
|
||||
onPlayPlaylist = viewModel::playPlaylist,
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Every cover below reports its load state to the tracker,
|
||||
// so the veil can wait for artwork instead of guessing.
|
||||
CompositionLocalProvider(
|
||||
LocalArtSettleTracker provides viewModel.artTracker,
|
||||
) {
|
||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||
}
|
||||
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||
// hide behind an "Updating your mixes…" wipe that stays up
|
||||
// until the screen has actually stopped moving.
|
||||
UpdatingVeil(visible = updating)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> HomeSkeletonContent()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "Welcome to Minstrel",
|
||||
body = "Nothing to show yet — scan a folder in your server " +
|
||||
"settings, then come back here for system playlists " +
|
||||
"and recommendations.",
|
||||
)
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load home",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.retry() },
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
sections = s.data,
|
||||
systemStatus = systemStatus,
|
||||
offline = offline,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||
onMostPlayedTap = viewModel::playMostPlayed,
|
||||
onPlayPool = viewModel::playPool,
|
||||
onPlayAlbum = viewModel::playAlbum,
|
||||
onPlayArtist = viewModel::playArtistShuffled,
|
||||
onPlayPlaylist = viewModel::playPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,10 +754,12 @@ private fun HomeSuccessContent(
|
||||
) {
|
||||
item {
|
||||
// Always rendered: real system/user playlists, with
|
||||
// placeholder cards filling the For You / Discover /
|
||||
// 3× Songs-like slots that haven't generated yet. When
|
||||
// offline, the cache-backed pool cards lead the row.
|
||||
// placeholder cards filling the For You / Discover slots
|
||||
// that haven't generated yet. When offline, the cache-backed
|
||||
// pool cards lead the row. Songs-like now lives in its own
|
||||
// dedicated row below (#1491), no longer inside this carousel.
|
||||
PlaylistsRow(
|
||||
title = "Playlists",
|
||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||
offline = offline,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -476,6 +767,18 @@ private fun HomeSuccessContent(
|
||||
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,
|
||||
@@ -499,6 +802,35 @@ 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(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
@@ -768,13 +1100,14 @@ private fun AlbumsRow(
|
||||
|
||||
@Composable
|
||||
private fun PlaylistsRow(
|
||||
title: String,
|
||||
rowItems: List<PlaylistRowItem>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Playlists") {
|
||||
HorizontalScrollRow(title = title) {
|
||||
itemsIndexed(items = rowItems) { _, item ->
|
||||
when (item) {
|
||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||
@@ -825,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
|
||||
/**
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* Online: For You + Discover fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
@@ -856,7 +1190,7 @@ internal fun buildPlaylistsRow(
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
@@ -868,12 +1202,7 @@ private fun buildOnlineRow(
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
@@ -881,16 +1210,44 @@ private fun buildOnlineRow(
|
||||
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 }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
@@ -905,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
else -> "pending"
|
||||
}
|
||||
|
||||
private const val SONGS_LIKE_SLOTS = 3
|
||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||
// 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
|
||||
|
||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
||||
when (val s = state) {
|
||||
is AlbumDetailUiState.Loading ->
|
||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load album",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AlbumDetailUiState.Success -> {
|
||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
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.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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 {
|
||||
|
||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
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.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
||||
} else {
|
||||
SkeletonArtistAlbumsGrid()
|
||||
}
|
||||
is ArtistDetailUiState.Error -> EmptyState(
|
||||
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
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.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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() {
|
||||
|
||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.observeArtists(),
|
||||
repository.observeAlbums(),
|
||||
) { artists, albums ->
|
||||
if (artists.isEmpty() && albums.isEmpty()) {
|
||||
UiState.Empty
|
||||
} else {
|
||||
UiState.Success(LibraryData(artists, albums))
|
||||
syncController.lastSyncError,
|
||||
) { artists, albums, syncError ->
|
||||
when {
|
||||
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||
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)
|
||||
|
||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
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.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
||||
class LikedTabViewModel @Inject constructor(
|
||||
private val repository: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
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 {
|
||||
refreshError.value = null
|
||||
runCatching { repository.refreshIds() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
||||
repository.observeLikedArtists(),
|
||||
repository.observeLikedAlbums(),
|
||||
repository.observeLikedTracks(),
|
||||
) { artists, albums, tracks ->
|
||||
refreshError,
|
||||
) { artists, albums, tracks, err ->
|
||||
val sections = LikedSections(artists, albums, tracks)
|
||||
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
||||
when {
|
||||
!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)
|
||||
|
||||
/**
|
||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
||||
body = "Tap the heart on an artist, album, or track to start " +
|
||||
"building your liked collection.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load likes",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> LikedContent(
|
||||
sections = s.data,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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 = "",
|
||||
)
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||
* enum name; the helper [wire] keeps that mapping in one place.
|
||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
||||
val name: String,
|
||||
val imageUrl: String = "",
|
||||
val attribution: List<SeedContributionRef> = emptyList(),
|
||||
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||
val matchedTags: List<String> = emptyList(),
|
||||
) {
|
||||
val attributionText: String
|
||||
get() {
|
||||
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle line for the card.
|
||||
*
|
||||
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||
* the graph ("adjacent to something you played") — the whole point of
|
||||
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||
* case: tag coverage for out-of-library artists is partial by nature
|
||||
* (#2376), so most candidates have no matched tags.
|
||||
*
|
||||
* Kept in lockstep with the web client's reasonText() in
|
||||
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||
*/
|
||||
val reasonText: String
|
||||
get() {
|
||||
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||
return when (tags.size) {
|
||||
0 -> attributionText
|
||||
1 -> "Matches your taste in ${tags[0]}."
|
||||
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggestion the user parked with "not right now" (#2374).
|
||||
*
|
||||
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||
* its own, and never reaches the taste profile. Anything that treats this as
|
||||
* negative preference signal is a bug.
|
||||
*
|
||||
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||
* decides whether a snooze is still in effect — every row the client receives
|
||||
* already is — so this is read purely to phrase "back in about 3 months".
|
||||
*/
|
||||
data class SuggestionSnoozeRef(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val snoozedUntil: String,
|
||||
) {
|
||||
/**
|
||||
* Relative return phrase for the manage list. Relative rather than a
|
||||
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||
* user never asked for.
|
||||
*
|
||||
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||
* is on screen, so the server still considers it snoozed, and guessing is
|
||||
* better than rendering an empty line.
|
||||
*/
|
||||
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||
.getOrNull()?.minus(nowMs)
|
||||
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||
// already lapsed by our clock though the server still returned the row
|
||||
// (the two disagree). Neither is "today", which would read as a real
|
||||
// prediction.
|
||||
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||
return when {
|
||||
days < 1 -> "today"
|
||||
days == 1 -> "tomorrow"
|
||||
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||
else -> {
|
||||
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||
if (months == 1) "in about a month" else "in about $months months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_DAY = 86_400_000.0
|
||||
|
||||
// Below this, days read more naturally than a rounded month count.
|
||||
//
|
||||
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||
// threshold above 30 sends all of those down the days branch instead.
|
||||
// This was 45 and the singular branch was dead code — the unit test
|
||||
// for it is what surfaced that.
|
||||
const val DAYS_BEFORE_MONTHS = 30
|
||||
const val DAYS_PER_MONTH = 30.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,11 @@ data class UserRef(
|
||||
val id: String,
|
||||
val username: String,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 = "",
|
||||
)
|
||||
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
|
||||
val name: String = "",
|
||||
@SerialName("image_url") val imageUrl: String = "",
|
||||
val attribution: List<SeedContributionWire> = emptyList(),
|
||||
/**
|
||||
* Tags this candidate shares with the user's taste profile, strongest
|
||||
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||
* out-of-library artists is partial by nature (#2376) — so the default
|
||||
* empty list is the common case, not an error.
|
||||
*/
|
||||
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -48,6 +55,36 @@ data class SeedContributionWire(
|
||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||
* with "not right now". The server only returns rows that are still in
|
||||
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||
* decide whether to show it; it reads it only to say when the artist comes
|
||||
* back.
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeWire(
|
||||
val mbid: String = "",
|
||||
val name: String = "",
|
||||
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||
*
|
||||
* [name] is required by the server, not decorative: suggestions are
|
||||
* out-of-library, so there is no artists row to resolve a display name from
|
||||
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||
*
|
||||
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||
* client-side would freeze the default at whatever this build shipped.
|
||||
*/
|
||||
@Serializable
|
||||
data class SnoozeSuggestionBody(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||
* payload shape. Optional fields are emitted only when non-null
|
||||
|
||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
||||
val at: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@@ -18,4 +18,5 @@ data class MyProfileWire(
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val email: String? = null,
|
||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||
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.auth.ui.LoginScreen
|
||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<AdminTagSources> {
|
||||
WithAnimatedScope {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
AdminTagSourcesScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object AdminRequests
|
||||
@Serializable data object AdminQuarantine
|
||||
@Serializable data object AdminUsers
|
||||
@Serializable data object AdminTagSources
|
||||
|
||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||
* 1-based Track index from GetPositionInfo.
|
||||
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||
* track the renderer is actually playing, matched by track identity
|
||||
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||
* index drift -- see [syncLocalCursorToRemote].
|
||||
*/
|
||||
class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||
// the notification next/prev only nudged the local cursor (which the poll
|
||||
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||
// Route them through the same Sonos path as the media-item variants when a
|
||||
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||
override fun seekToNext() {
|
||||
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long =
|
||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||
|
||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
* index when not in queue load.
|
||||
*
|
||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
||||
* because during load Sonos reports Track=1 while we're still
|
||||
* appending, and syncing would race the SetAV+Seek that lands
|
||||
* after. Outside load, forward sync catches Sonos auto-advances
|
||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
||||
* drift after a brief poll-failure burst that didn't trip the
|
||||
* drop threshold. Forward-only because a Next override we just
|
||||
* issued can race with a poll still reporting the prior Track --
|
||||
* the next poll catches up safely.
|
||||
* [remoteState], and sync the local cursor to the track the renderer is
|
||||
* actually playing (see [syncLocalCursorToRemote]).
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
)
|
||||
maybeSyncLocalCursor(info.track)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||
if (holder.target.value != null) return
|
||||
if (sonosTrack <= 0) return
|
||||
val sonosIdx = sonosTrack - 1
|
||||
/**
|
||||
* Align the paused local delegate cursor to the track the renderer is
|
||||
* actually playing, so the un-overridden current-item getters
|
||||
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||
* resolve to that track. This is THE single source of truth for "what's
|
||||
* playing" during a cast.
|
||||
*
|
||||
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||
* too. Runs on the application looper (delegate access contract).
|
||||
*
|
||||
* Suppressed while:
|
||||
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||
* (re)uploaded, so its Track index is meaningless; and
|
||||
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||
* the override already advanced the delegate optimistically, and Sonos
|
||||
* still reports the OLD track for a beat, so syncing now would undo the
|
||||
* user's press. The pending deadline is honoured directly so a missed
|
||||
* clear can't wedge the sync.
|
||||
*/
|
||||
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||
if (isLoadingUpnp()) return
|
||||
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||
handler.post {
|
||||
val localIdx = delegate.currentMediaItemIndex
|
||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||
if (target != delegate.currentMediaItemIndex) {
|
||||
Timber.w(
|
||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||
localIdx, sonosIdx,
|
||||
"UPnP cursor sync: local=%d -> %d",
|
||||
delegate.currentMediaItemIndex, target,
|
||||
)
|
||||
delegate.seekTo(sonosIdx, 0L)
|
||||
delegate.seekTo(target, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||
* Must run on the application looper -- reads the delegate timeline.
|
||||
*/
|
||||
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||
}
|
||||
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||
*/
|
||||
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||
var best: Int? = null
|
||||
var bestDist = Int.MAX_VALUE
|
||||
for (i in 0 until delegate.mediaItemCount) {
|
||||
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||
val dist = abs(i - preferNear)
|
||||
if (dist < bestDist) {
|
||||
best = i
|
||||
bestDist = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||
private fun trackIdFromStreamUri(uri: String): String? {
|
||||
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||
if (start < 0) return null
|
||||
val idStart = start + TRACKS_PATH_MARKER.length
|
||||
val idEnd = uri.indexOf('/', idStart)
|
||||
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Path segment that precedes the track-id in a stream URI
|
||||
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||
// back to a delegate MediaItem by identity.
|
||||
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
const val NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
|
||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
trackId = trackId,
|
||||
clientId = cid,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
if (curTrackId == trackId) {
|
||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
atIso = startedAt.toString(),
|
||||
durationPlayedMs = durationPlayedMs,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
||||
|
||||
// ── 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) {
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playlists: PlaylistsRepository,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
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 —
|
||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||
@@ -278,6 +288,37 @@ class PlayerController @Inject constructor(
|
||||
controller.addMediaItem(track.toMediaItem(source = null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||
* index). No-op on bad indices or a no-move.
|
||||
*/
|
||||
fun moveInQueue(from: Int, to: Int) {
|
||||
val controller = mediaController ?: return
|
||||
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||
controller.moveMediaItem(from, to)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the queue item at [index]. When it's the currently-playing item
|
||||
* Media3 advances to the next automatically. No-op on a bad index.
|
||||
*/
|
||||
fun removeFromQueue(index: Int) {
|
||||
val controller = mediaController ?: return
|
||||
if (index !in queueRefs.indices) return
|
||||
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||
controller.removeMediaItem(index)
|
||||
}
|
||||
|
||||
/** Empty the queue and stop playback. */
|
||||
fun clearQueue() {
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = emptyList()
|
||||
controller.clearMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||
* "radio:<id>" so the server-side rotation reporter can
|
||||
@@ -351,7 +392,12 @@ class PlayerController @Inject constructor(
|
||||
if (current == null || upnpEngaged) return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
if (!isZeroDuration) {
|
||||
// A track loaded with real audio — clear the self-heal budget so
|
||||
// a later stale queue can recover again.
|
||||
selfHealAttempts = 0
|
||||
return
|
||||
}
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
@@ -367,6 +413,58 @@ 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 ──────────
|
||||
|
||||
private suspend fun connectAndObserve() {
|
||||
@@ -417,6 +515,17 @@ class PlayerController @Inject constructor(
|
||||
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(
|
||||
@@ -520,18 +629,19 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||
* wrapped player as defense in depth.
|
||||
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||
* queue index + current track are owned solely by [onEvents], which reads
|
||||
* the authoritative delegate cursor: during a cast,
|
||||
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||
* the track the renderer is actually playing (matched by identity), so there
|
||||
* is exactly one writer of "what's playing" and a stale tick can never
|
||||
* revert it to the old track. (Previously the tick was a second index writer
|
||||
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||
*/
|
||||
private fun tickPositionPoll(controller: MediaController) {
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
resolvePendingTransport(controller, upnpActive)
|
||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
||||
val effectiveIsPlaying =
|
||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||
val effectivePosition = if (upnpActive) {
|
||||
@@ -539,27 +649,29 @@ class PlayerController @Inject constructor(
|
||||
} else {
|
||||
controller.currentPosition
|
||||
}
|
||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||
val idx = controller.currentMediaItemIndex
|
||||
val current = uiStateInternal.value
|
||||
val newPos = effectivePosition.coerceAtLeast(0)
|
||||
val newDur = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
controller.duration,
|
||||
desiredIdx = desiredIdx,
|
||||
controllerIdx = controller.currentMediaItemIndex,
|
||||
desiredIdx = idx,
|
||||
controllerIdx = idx,
|
||||
)
|
||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||
// Track adjustments are forward-only AND suppressed while a user
|
||||
// transport press is pending Sonos confirmation. Together those keep
|
||||
// either direction of user input from being undone by a stale poll.
|
||||
val trackChanged = !pendingTransport &&
|
||||
desiredIdx > current.queueIndex &&
|
||||
desiredIdx in queueRefs.indices
|
||||
publishTickIfChanged(
|
||||
current, trackChanged, desiredIdx,
|
||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||
)
|
||||
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur ||
|
||||
current.bufferedPositionMs != newBuf
|
||||
if (somethingChanged) {
|
||||
uiStateInternal.value = current.copy(
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,48 +688,6 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
||||
private fun publishTickIfChanged(
|
||||
current: PlayerUiState,
|
||||
trackChanged: Boolean,
|
||||
desiredIdx: Int,
|
||||
effectiveIsPlaying: Boolean,
|
||||
newPos: Long,
|
||||
newDur: Long,
|
||||
newBuf: Long,
|
||||
) {
|
||||
val somethingChanged = trackChanged ||
|
||||
current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur
|
||||
if (!somethingChanged) return
|
||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
||||
uiStateInternal.value = current.copy(
|
||||
currentTrack = newTrack,
|
||||
queueIndex = newIdx,
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
// Intentionally do NOT call controller.seekTo here. That would route
|
||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
||||
// start of the same track it's already playing, restarting the song.
|
||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
||||
// the next pollOnce catches up; the uiState above already reflects
|
||||
// Sonos's truth for the user.
|
||||
}
|
||||
|
||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
||||
if (upnpActive) {
|
||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
controller.currentMediaItemIndex
|
||||
}
|
||||
|
||||
// ── Remote position interpolation state ──────────────────────────────
|
||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||
@@ -763,6 +833,10 @@ class PlayerController @Inject constructor(
|
||||
// they don't need this poll.
|
||||
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
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
|
||||
@@ -133,7 +133,7 @@ private fun RouteRow(
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = route.description ?: defaultSubtitle(route)
|
||||
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
// isSelected (route == the active/selected route) drives the Bluetooth
|
||||
// "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.Wired -> "Wired"
|
||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Cast -> "Cast"
|
||||
OutputRoute.Kind.Other -> "Available"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
||||
val description: String?,
|
||||
val kind: Kind,
|
||||
val protocol: Protocol,
|
||||
val isConnected: Boolean,
|
||||
) {
|
||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||
|
||||
@@ -43,9 +42,11 @@ data class OutputRoute(
|
||||
/**
|
||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||
* device types fall through to [Kind.Other]. The
|
||||
* `connectionState` proxy is good enough for the chip's
|
||||
* "Connected"/"Available" subtitle.
|
||||
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||
* intrinsic of the route — the caller derives it by comparing to
|
||||
* the selected route (RouteSnapshot.current), because
|
||||
* RouteInfo.connectionState only reflects remote-route handshakes
|
||||
* and stays DISCONNECTED for local SYSTEM routes even when in use.
|
||||
*/
|
||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||
val kind = when (route.deviceType) {
|
||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||
else -> Kind.Other
|
||||
}
|
||||
val connected =
|
||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
kind = kind,
|
||||
protocol = Protocol.SYSTEM,
|
||||
isConnected = connected,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
||||
* network speakers into the same `OutputPickerController`
|
||||
* routes stream the system routes come through.
|
||||
*
|
||||
* `isConnected = false` because UPnP devices have no
|
||||
* MediaRouter connection-state concept — they're always
|
||||
* "available" on the LAN, and the picker's selected-route
|
||||
* rendering handles the "currently playing" indicator.
|
||||
* UPnP devices have no MediaRouter connection-state concept —
|
||||
* they're always "available" on the LAN, and the picker's
|
||||
* selected-route rendering handles the "currently playing"
|
||||
* indicator.
|
||||
*
|
||||
* Subtitle is `manufacturer modelName` joined by a single
|
||||
* space, falling back to "Network speaker" when both fields
|
||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
||||
description = description,
|
||||
kind = Kind.Other,
|
||||
protocol = Protocol.UPNP,
|
||||
isConnected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
||||
* stub-test for ViewModel-level logic when it grows).
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val controller: PlayerController,
|
||||
private val likes: LikesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||
|
||||
/**
|
||||
* Reactive set of liked track ids — the set-based pattern the queue
|
||||
* (and playlist/album/artist detail) uses to color each row's
|
||||
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
|
||||
* per-row Flow subscription.
|
||||
*/
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
fun play() = controller.play()
|
||||
fun pause() = controller.pause()
|
||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||
fun toggleShuffle() = controller.toggleShuffle()
|
||||
fun cycleRepeat() = controller.cycleRepeat()
|
||||
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||
fun clearQueue() = controller.clearQueue()
|
||||
|
||||
fun toggleLikeTrack(trackId: String) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.composables.icons.lucide.Volume2
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
|
||||
* not just a way to satisfy the analyzer: the row now carries two gestures, a
|
||||
* swipe background, and its own accessibility surface, which is more behaviour
|
||||
* than the screen that lists it. `internal` rather than `private` only because
|
||||
* QueueList (still in QueueScreen.kt) is the caller.
|
||||
*/
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
internal fun QueueRow(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
) {
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||
val highlight = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
// Swipe left to remove, replacing the X button (#2395 follow-up). Only
|
||||
// end-to-start is enabled: a right-swipe has no meaning here, and leaving it
|
||||
// live would delete tracks on a mis-aimed gesture in either direction.
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onRemove()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = { RemoveSwipeBackground() },
|
||||
// The reorder lift lives out here so a row being dragged vertically
|
||||
// carries its swipe container with it rather than sliding out of one.
|
||||
modifier = Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY },
|
||||
) {
|
||||
QueueRowContent(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
isCurrent = isCurrent,
|
||||
liked = liked,
|
||||
highlight = highlight,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onClick = onClick,
|
||||
onToggleLike = onToggleLike,
|
||||
onRemove = onRemove,
|
||||
onMove = onMove,
|
||||
onDragOffset = { dragOffsetY = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueRowContent(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
highlight: Color,
|
||||
rowHeightPx: Int,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragOffset: (Float) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Opaque: this sits ON TOP of the red remove background, so a
|
||||
// transparent row would show the fill through it at rest.
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.queueReorderActions(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
onMove = onMove,
|
||||
onRemove = onRemove,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The album art IS the grab surface (#2395). The grip icon it replaces
|
||||
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
|
||||
// narrowest surface in the app, competing with the title for space.
|
||||
QueueRowThumbnail(
|
||||
track = track,
|
||||
dragModifier = Modifier.queueReorderDrag(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = onDragOffset,
|
||||
onMove = onMove,
|
||||
),
|
||||
)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the row slides off to reveal: the destructive colour with a trash glyph,
|
||||
* pinned to the trailing edge because that is the edge the swipe uncovers.
|
||||
*
|
||||
* Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design
|
||||
* system keeps those apart deliberately — an error is a failure that already
|
||||
* happened, a destructive action is one about to happen — and using the error
|
||||
* colour here would dress an intentional gesture as a fault report.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoveSwipeBackground() {
|
||||
val actions = LocalActionColors.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(actions.destructive)
|
||||
.padding(horizontal = 24.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction)
|
||||
Text(
|
||||
text = "Remove",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = actions.onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-reader reordering and removal for a queue row.
|
||||
*
|
||||
* Both gestures this row now relies on — long-press-drag to reorder, swipe to
|
||||
* remove — are touch-only and unavailable under TalkBack, and each replaced a
|
||||
* control that a screen reader COULD find (the grip's "Reorder track", the X's
|
||||
* "Remove from queue"). Without these actions the row would have lost both
|
||||
* capabilities for anyone not using touch. They're the Android counterpart to
|
||||
* the web row's ArrowUp/ArrowDown keys and its still-present X button.
|
||||
*/
|
||||
private fun Modifier.queueReorderActions(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
): Modifier = semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction("Move up") {
|
||||
if (index > 0) { onMove(index, index - 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Move down") {
|
||||
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder-drag behaviour for a queue row, applied to whatever element is the
|
||||
* grab surface — the album art, since #2395 removed the grip icon.
|
||||
*
|
||||
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
|
||||
* is the load-bearing detail. The grip was a small target, so a plain drag
|
||||
* gesture on it never competed with anything. A 48dp thumbnail is a large
|
||||
* chunk of every row, and with a plain drag detector any vertical pan starting
|
||||
* on artwork would be swallowed as a row-reorder instead of scrolling the
|
||||
* queue — the list would feel broken precisely where it's easiest to touch.
|
||||
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
|
||||
* tap still plays (the detector doesn't consume a plain tap, so it falls
|
||||
* through to the row's clickable).
|
||||
*/
|
||||
private fun Modifier.queueReorderDrag(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
rowHeightPx: Int,
|
||||
onOffsetChange: (Float) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
): Modifier = composed {
|
||||
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||
// release we translate the accumulated offset into a row delta and reorder.
|
||||
var offset by remember { mutableFloatStateOf(0f) }
|
||||
pointerInput(index, queueSize, rowHeightPx) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offset += dragAmount.y
|
||||
onOffsetChange(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||
if (target != index) onMove(index, target)
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
onDragCancel = {
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowThumbnail(track: TrackRef, dragModifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(dragModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ServerImage(
|
||||
url = track.coverUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
@@ -1,17 +1,18 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -20,22 +21,24 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowDown
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Volume2
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -44,16 +47,35 @@ fun QueueScreen(
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Queue") },
|
||||
title = {
|
||||
Column {
|
||||
Text("Queue")
|
||||
if (state.queue.isNotEmpty()) {
|
||||
Text(
|
||||
text = queueSummary(state.queue),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.queue.isNotEmpty()) {
|
||||
IconButton(onClick = viewModel::clearQueue) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
@@ -67,87 +89,106 @@ fun QueueScreen(
|
||||
QueueList(
|
||||
tracks = state.queue,
|
||||
currentIndex = state.queueIndex,
|
||||
likedTrackIds = likedTrackIds,
|
||||
onJumpTo = viewModel::seekToIndex,
|
||||
onToggleLike = viewModel::toggleLikeTrack,
|
||||
onMove = viewModel::moveInQueue,
|
||||
onRemove = viewModel::removeFromQueue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueList(
|
||||
tracks: List<TrackRef>,
|
||||
currentIndex: Int,
|
||||
likedTrackIds: Set<String>,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleLike: (String) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
isCurrent = index == currentIndex,
|
||||
onClick = { onJumpTo(index) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||
// near the visible window — if the user has scrolled away to browse, leave
|
||||
// them there (the pill offers the way back). Parity with the web queue.
|
||||
LaunchedEffect(currentIndex) {
|
||||
if (currentIndex < 0) return@LaunchedEffect
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
val first = visible.firstOrNull()?.index ?: 0
|
||||
val last = visible.lastOrNull()?.index ?: 0
|
||||
if (currentIndex in (first - 1)..(last + 1)) {
|
||||
listState.animateScrollToItem(currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val currentVisible by remember {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = tracks.size,
|
||||
isCurrent = index == currentIndex,
|
||||
liked = track.id in likedTrackIds,
|
||||
onClick = { onJumpTo(index) },
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onRemove = { onRemove(index) },
|
||||
onMove = onMove,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
JumpToCurrentPill(
|
||||
visible = currentIndex >= 0 && !currentVisible,
|
||||
onClick = {
|
||||
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
val highlight = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
private fun JumpToCurrentPill(
|
||||
visible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||
FilledTonalButton(onClick = onClick) {
|
||||
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Jump to current")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
/** "N tracks · 12 min" header summary. */
|
||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||
} else {
|
||||
"$minutes min"
|
||||
}
|
||||
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||
return "${tracks.size} $noun · $length"
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
||||
throw e
|
||||
}
|
||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||
playlistTrackDao.deleteByPlaylist(id)
|
||||
playlistTrackDao.upsertAll(
|
||||
wire.tracks.mapNotNull { row ->
|
||||
playlistTrackDao.replacePlaylistTracks(
|
||||
playlistId = id,
|
||||
rows = wire.tracks.mapNotNull { row ->
|
||||
row.trackId?.let { trackId ->
|
||||
CachedPlaylistTrackEntity(
|
||||
playlistId = id,
|
||||
|
||||
@@ -28,15 +28,17 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -56,6 +58,8 @@ import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.RefreshCw
|
||||
import com.composables.icons.lucide.Shuffle
|
||||
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.LiveEvent
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
@@ -72,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
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.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||
@@ -113,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -139,6 +145,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||
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>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
@@ -154,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("playlist.") }
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +186,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
* ignored — the playlists-list screen handles those.
|
||||
*/
|
||||
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
|
||||
if (eventPlaylistId != playlistId) return
|
||||
when (event.kind) {
|
||||
@@ -171,6 +200,14 @@ 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) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
@@ -220,6 +257,30 @@ 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). */
|
||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
@@ -261,8 +322,25 @@ 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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentTitle(state)) },
|
||||
@@ -301,7 +379,11 @@ private fun PlaylistDetailContent(
|
||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||
is PlaylistDetailUiState.Loading ->
|
||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load playlist",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is PlaylistDetailUiState.Success -> {
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||
val playerState by playerViewModel.uiState.collectAsState()
|
||||
@@ -585,33 +667,6 @@ 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||
|
||||
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
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.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
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
|
||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Live updates: a playlist created/updated/deleted from another
|
||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("playlist.") }
|
||||
.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. */
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
refreshError.value = null
|
||||
runCatching { repository.refreshList() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||
repository.observeAll()
|
||||
.map { list ->
|
||||
if (list.isEmpty()) {
|
||||
UiState.Empty
|
||||
} else {
|
||||
UiState.Success(list)
|
||||
}
|
||||
combine(repository.observeAll(), refreshError) { list, err ->
|
||||
when {
|
||||
list.isNotEmpty() -> UiState.Success(list)
|
||||
// Empty cache + failed refresh is a load failure, not "no
|
||||
// playlists yet" — surface Error so the user gets a Retry.
|
||||
err != null -> UiState.Error(err)
|
||||
else -> UiState.Empty
|
||||
}
|
||||
}
|
||||
.asCacheFirstStateFlow(viewModelScope)
|
||||
}
|
||||
|
||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
||||
"appear once your library has enough plays. Create your " +
|
||||
"own playlist from any album or track in the meantime.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load playlists",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> PlaylistsGrid(
|
||||
playlists = s.data,
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.models.QuarantineRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
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.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
||||
"and it'll show up here. The track stays out of system " +
|
||||
"playlists until you unhide it.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load hidden tracks",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> HiddenList(
|
||||
rows = s.data,
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.models.QuarantineRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
class HiddenTabViewModel @Inject constructor(
|
||||
private val repository: QuarantineRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
.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 {
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
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
|
||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
||||
body = "Use Discover to ask Lidarr for new music; your " +
|
||||
"requests show up here.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load requests",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is UiState.Success -> RequestList(
|
||||
rows = s.data,
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
||||
.collect { silentReload() }
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
// 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)
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
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.MainAppBarActions
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
||||
onTrackPlay = viewModel::playTrack,
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
||||
onTrackPlay: (TrackRef) -> Unit,
|
||||
onNavigateToAlbum: (String) -> Unit,
|
||||
onNavigateToArtist: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||
SearchResultsState.Loading -> LoadingCentered()
|
||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||
is SearchResultsState.Error -> ErrorRetry(
|
||||
title = "Search failed",
|
||||
message = state.message,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
is SearchResultsState.Loaded -> {
|
||||
if (state.response.isEmpty) {
|
||||
CenteredHint(
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.TrackRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -43,6 +45,7 @@ data class SearchState(
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val repository: SearchRepository,
|
||||
private val player: PlayerController,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
||||
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) {
|
||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
||||
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
|
||||
* track-results list starting at the tapped entry, matching Flutter.
|
||||
|
||||
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One of three terminal states the Check-for-updates button surfaces.
|
||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
||||
* system installer via [ApkInstaller].
|
||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||
* installs it via [ApkInstaller].
|
||||
*/
|
||||
sealed interface UpdateCheckResult {
|
||||
data object Idle : UpdateCheckResult
|
||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
val isChecking: Boolean = false,
|
||||
val isInstalling: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||
)
|
||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
||||
* [UpdateRepository.getLatest], compares versus the build's
|
||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and hands it to the system installer — routing the
|
||||
* user to the "install unknown apps" settings page first when that
|
||||
* permission hasn't been granted.
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
* granted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AboutCardViewModel @Inject constructor(
|
||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (internal.value.isInstalling) return
|
||||
if (internal.value.installStage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
internal.update {
|
||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
internal.update { it.copy(isInstalling = false) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
val why = ErrorCopy.fromThrowable(e)
|
||||
internal.update {
|
||||
it.copy(
|
||||
isInstalling = false,
|
||||
installMessage = "Couldn't download update: $why",
|
||||
)
|
||||
}
|
||||
internal.update {
|
||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// The install half now suspends on the platform's verdict, so it
|
||||
// gets its own stage — reporting "Downloading…" through it would
|
||||
// be a lie once a confirm dialog is on screen.
|
||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||
val outcome = installer.install(apk)
|
||||
internal.update {
|
||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
internal.update {
|
||||
it.copy(
|
||||
installStage = InstallStage.ERROR,
|
||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -94,6 +97,7 @@ fun SettingsScreen(
|
||||
onPickTheme = themeVm::setThemeMode,
|
||||
onNavToRequests = { navController.navigate(Requests) },
|
||||
onNavToAdmin = { navController.navigate(Admin) },
|
||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||
onSignOutClick = { showSignOutConfirm = true },
|
||||
)
|
||||
}
|
||||
@@ -116,6 +120,7 @@ private fun SettingsList(
|
||||
onPickTheme: (ThemeMode) -> Unit,
|
||||
onNavToRequests: () -> Unit,
|
||||
onNavToAdmin: () -> Unit,
|
||||
onToggleDiagnostics: (Boolean) -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -148,6 +153,12 @@ private fun SettingsList(
|
||||
ProfileCard()
|
||||
PasswordCard()
|
||||
ListenBrainzCard()
|
||||
if (state.diagnosticsEnabledByAdmin) {
|
||||
DiagnosticsCard(
|
||||
optOut = state.diagnosticsOptOut,
|
||||
onToggle = onToggleDiagnostics,
|
||||
)
|
||||
}
|
||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||
StorageCard()
|
||||
AboutCard()
|
||||
@@ -262,6 +273,46 @@ 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)
|
||||
@Composable
|
||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||
@@ -332,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
enabled = !state.isChecking && !state.isInstalling,
|
||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isChecking) {
|
||||
@@ -344,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (available != null) {
|
||||
InstallButton(
|
||||
version = available.info.version,
|
||||
isInstalling = state.isInstalling,
|
||||
stage = state.installStage,
|
||||
onClick = { viewModel.install(available.info) },
|
||||
)
|
||||
}
|
||||
@@ -358,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = !isInstalling,
|
||||
enabled = !stage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isInstalling) {
|
||||
if (stage.isBusy()) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(if (isInstalling) "Downloading…" else "Install $version")
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install $version"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,15 @@ data class SettingsState(
|
||||
val isAdmin: Boolean = false,
|
||||
val isSigningOut: 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
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authController: AuthController,
|
||||
authStore: AuthStore,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val transient = MutableStateFlow(TransientState())
|
||||
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
|
||||
combine(
|
||||
authStore.baseUrl,
|
||||
authController.currentUser,
|
||||
authStore.diagnosticsOptOut,
|
||||
transient,
|
||||
) { url, user, t ->
|
||||
) { url, user, optOut, t ->
|
||||
SettingsState(
|
||||
serverUrl = url,
|
||||
username = user?.username.orEmpty(),
|
||||
isAdmin = user?.isAdmin == true,
|
||||
isSigningOut = t.isSigningOut,
|
||||
signedOut = t.signedOut,
|
||||
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
||||
diagnosticsOptOut = optOut,
|
||||
)
|
||||
}.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() {
|
||||
if (transient.value.isSigningOut) return
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.fabledsword.minstrel.shared
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
// Once raised, the veil stays up at least this long. Without a floor a
|
||||
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||
|
||||
// The screen must stop changing for this long before the veil lowers.
|
||||
// Every content change re-arms it, so a refresh that lands in stages
|
||||
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||
private const val DEFAULT_QUIET_MS = 700L
|
||||
|
||||
// Hard ceiling on visibility. A refresh that never settles must not
|
||||
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||
|
||||
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||
// that fails on the first try gets another go before the user sees
|
||||
// anything, instead of the veil wiping off over unchanged content.
|
||||
private const val DEFAULT_ATTEMPTS = 3
|
||||
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||
|
||||
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||
data class VeilTimings(
|
||||
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||
)
|
||||
|
||||
/**
|
||||
* A snapshot of everything that visibly moves on the veiled screen.
|
||||
*
|
||||
* @param contentKey any value whose equality tracks what's rendered — a
|
||||
* change means the screen moved, and re-arms the quiet timer.
|
||||
* @param hasContent true when real content (not a skeleton or an empty
|
||||
* state) is on screen. The veil waits for this before raising: there's
|
||||
* nothing to hide until there's something to hide.
|
||||
* @param quiescent false while something is still landing (artwork
|
||||
* loading, tiles hydrating). The veil will not lower until this is
|
||||
* true, up to [VeilTimings.maxHoldMs].
|
||||
*/
|
||||
data class VeilSettleState(
|
||||
val contentKey: Any?,
|
||||
val hasContent: Boolean,
|
||||
val quiescent: Boolean,
|
||||
)
|
||||
|
||||
/** What a finished session did, so callers can report it if they want. */
|
||||
enum class VeilOutcome {
|
||||
/** Content changed, and the veil covered the churn. */
|
||||
CHANGED,
|
||||
|
||||
/** The refresh worked, but nothing on screen moved — already current. */
|
||||
UNCHANGED,
|
||||
|
||||
/** Every attempt failed. */
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's result, plus whether a user explicitly asked for it.
|
||||
*
|
||||
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||
* changed", while the same outcome from a background check is noise.
|
||||
*/
|
||||
data class VeilSessionResult(
|
||||
val outcome: VeilOutcome,
|
||||
val userInitiated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives an "updating" overlay from *observed content change and settling*
|
||||
* rather than from a fixed delay.
|
||||
*
|
||||
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||
* lowers while the screen is still moving, because finishing the network
|
||||
* pull is nowhere near the end of the visible work — the pull writes id
|
||||
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||
* (issue #2327).
|
||||
*
|
||||
* So instead: run [work], raise only if the content actually changes, then
|
||||
* hold until [settleSignal] reports the screen has stopped changing for
|
||||
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||
*
|
||||
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||
* already on screen — the common case on a launch over a warm cache —
|
||||
* raises nothing at all, because a veil over an unchanged screen hides
|
||||
* nothing and only delays first paint. The cost is that the veil arrives
|
||||
* one emission after the change, so a single atomic content swap shows
|
||||
* through; everything messier that follows it (tile hydration, then
|
||||
* artwork) still lands behind the veil.
|
||||
*
|
||||
* Overlapping triggers extend the running session instead of racing it,
|
||||
* so the veil stays up continuously rather than lowering and re-raising.
|
||||
*
|
||||
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||
* exactly as before. Callers that want to surface a failure can do it from
|
||||
* [onSessionEnd] instead.
|
||||
*
|
||||
* @param work one refresh attempt; returns true when it succeeded.
|
||||
* @param shouldVeil sampled at session start — "is there cached content
|
||||
* this refresh is about to overwrite?". False means a cold load, where
|
||||
* a skeleton is the right affordance, and the work runs unveiled.
|
||||
* @param onSessionEnd called once per finished session, on the controller's
|
||||
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||
*/
|
||||
class UpdateVeilController(
|
||||
private val scope: CoroutineScope,
|
||||
private val settleSignal: Flow<VeilSettleState>,
|
||||
private val shouldVeil: suspend () -> Boolean,
|
||||
private val timings: VeilTimings = VeilTimings(),
|
||||
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||
private val work: suspend () -> Boolean,
|
||||
) {
|
||||
private val visibleInternal = MutableStateFlow(false)
|
||||
|
||||
/** True while the veil should be drawn over the screen. */
|
||||
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||
|
||||
private val finishedInternal = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Increments as each session ends. Lets a caller wait for "this
|
||||
* refresh is done" without knowing whether a veil ever went up —
|
||||
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||
* refresh never raises one.
|
||||
*/
|
||||
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||
|
||||
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||
// together) collapses into one follow-up pass, not a queue of them.
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
// Sticky across a conflated burst: conflation drops the older token, so
|
||||
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||
// trigger was the user's, the session still owes them an answer.
|
||||
private val userAsked = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
// One consumer, so sessions are serialised by construction: two
|
||||
// triggers can never each own a piece of the veil's state.
|
||||
scope.launch {
|
||||
while (true) {
|
||||
requests.receive()
|
||||
runSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||
* calls arriving during a session extend it rather than starting a
|
||||
* competing one.
|
||||
*
|
||||
* @param userInitiated true when a person explicitly asked (pull to
|
||||
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||
* through to [onSessionEnd].
|
||||
*/
|
||||
fun request(userInitiated: Boolean = false) {
|
||||
if (userInitiated) userAsked.set(true)
|
||||
requests.trySend(Unit)
|
||||
}
|
||||
|
||||
private suspend fun runSession() {
|
||||
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||
// (see [drainWork]) may have been the user's, and they're still owed
|
||||
// an answer for it.
|
||||
val askedAtStart = userAsked.getAndSet(false)
|
||||
if (!shouldVeil()) {
|
||||
// Cold load: the skeleton is the right affordance, so no veil.
|
||||
// Succeeding here did change the screen — from nothing to
|
||||
// something — so it reports CHANGED, never "already up to date".
|
||||
val ok = drainWork()
|
||||
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||
return
|
||||
}
|
||||
val raised = CompletableDeferred<Unit>()
|
||||
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||
// Floor and ceiling are measured from the raise, not the request,
|
||||
// so a late raise still gets its full no-flash minimum.
|
||||
val floor = scope.launch {
|
||||
raised.await()
|
||||
delay(timings.minHoldMs)
|
||||
}
|
||||
val ceiling = scope.launch {
|
||||
raised.await()
|
||||
delay(timings.maxHoldMs)
|
||||
visibleInternal.value = false
|
||||
}
|
||||
var succeeded = false
|
||||
try {
|
||||
succeeded = drainWork()
|
||||
// Always wait for the settle, never conditionally on `visible`:
|
||||
// work that finishes without suspending would otherwise reach
|
||||
// here before the raiser has been dispatched, tear the session
|
||||
// down, and leave the churn uncovered. This wait is also what
|
||||
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||
// change?" — a change landing just after the pull returns still
|
||||
// gets seen.
|
||||
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||
// Honour the no-flash minimum before lowering. Deliberately in
|
||||
// the try and not the finally: on cancellation the scope is
|
||||
// going away and nothing will render the veil, so the floor is
|
||||
// pointless there — and a finally that suspends is a finally
|
||||
// that can resist teardown.
|
||||
if (raised.isCompleted) floor.join()
|
||||
} finally {
|
||||
raiser.cancel()
|
||||
ceiling.cancel()
|
||||
floor.cancel()
|
||||
visibleInternal.value = false
|
||||
finish(
|
||||
succeeded = succeeded,
|
||||
changed = raised.isCompleted,
|
||||
userInitiated = askedAtStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the veil the moment the screen's content differs from what was
|
||||
* already on it — and never, if this refresh turns out to be a no-op.
|
||||
*
|
||||
* The baseline is the first state that HAS content, not simply the first
|
||||
* state: over a warm cache the cached rows paint a moment after the
|
||||
* session starts, and treating that first paint as "a change" would veil
|
||||
* every launch, which is the whole thing this avoids.
|
||||
*/
|
||||
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||
val baseline = settleSignal.first { it.hasContent }
|
||||
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||
visibleInternal.value = true
|
||||
raised.complete(Unit)
|
||||
}
|
||||
|
||||
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||
val outcome = when {
|
||||
!succeeded -> VeilOutcome.FAILED
|
||||
changed -> VeilOutcome.CHANGED
|
||||
else -> VeilOutcome.UNCHANGED
|
||||
}
|
||||
onSessionEnd(
|
||||
VeilSessionResult(
|
||||
outcome = outcome,
|
||||
// Fold in a mid-session request from the user.
|
||||
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||
),
|
||||
)
|
||||
finishedInternal.update { it + 1 }
|
||||
}
|
||||
|
||||
/** True when the refresh eventually succeeded. */
|
||||
private suspend fun drainWork(): Boolean {
|
||||
var succeeded: Boolean
|
||||
do {
|
||||
succeeded = runWorkWithRetries()
|
||||
// A trigger that arrived mid-session gets folded into this one.
|
||||
} while (requests.tryReceive().isSuccess)
|
||||
return succeeded
|
||||
}
|
||||
|
||||
private suspend fun runWorkWithRetries(): Boolean {
|
||||
repeat(timings.attempts) { attempt ->
|
||||
if (work()) return true
|
||||
if (attempt < timings.attempts - 1) {
|
||||
delay(timings.retryBackoffMs * (attempt + 1))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the screen has been unchanged for
|
||||
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||
*
|
||||
* `debounce` is what makes this hold across a staged update: every
|
||||
* change restarts the window, so the veil lowers only once emissions
|
||||
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||
* still-loading moment and waits for the next lull.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun awaitSettled() {
|
||||
settleSignal
|
||||
.distinctUntilChanged()
|
||||
.debounce(timings.quietMs)
|
||||
.first { it.quiescent }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Counts the cover-art loads that are currently in flight, so a
|
||||
* screen-level overlay can wait for the artwork to actually land instead
|
||||
* of guessing with a fixed delay.
|
||||
*
|
||||
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||
* hydrated (title, artist, counts all present) and still snap its cover
|
||||
* in a second later, which is exactly the churn the "Updating your
|
||||
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||
* it finishes long before Coil does — so the composition reports it
|
||||
* upward here instead.
|
||||
*
|
||||
* [ServerImage] reports into whatever tracker it finds in
|
||||
* [LocalArtSettleTracker], which means every art surface in the app
|
||||
* participates for free. Only *composed* images are counted, so a
|
||||
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||
* the pop-in a user can actually see.
|
||||
*
|
||||
* Provide one per screen that needs it (typically owned by the
|
||||
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||
*
|
||||
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||
*/
|
||||
@Stable
|
||||
class ArtSettleTracker {
|
||||
private val inFlightInternal = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* How many on-screen images are still loading. Zero means the
|
||||
* artwork has settled — every composed cover has either drawn or
|
||||
* failed to a fallback.
|
||||
*/
|
||||
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||
|
||||
fun begin() {
|
||||
inFlightInternal.update { it + 1 }
|
||||
}
|
||||
|
||||
fun end() {
|
||||
// Floor at zero: a decrement can outlive its increment when a
|
||||
// tile is disposed mid-load and the count must not go negative
|
||||
// and wedge "settled" off forever.
|
||||
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracker [ServerImage] reports load state to, or null on screens
|
||||
* that don't care (the default) — reporting is then a no-op.
|
||||
*/
|
||||
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* ServerImage + fallback structure was identical at all three sites.
|
||||
*
|
||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
||||
* `Modifier.align(...)` inside it.
|
||||
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||
* 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
|
||||
fun CoverTile(
|
||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape)
|
||||
.background(background),
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ServerImage(
|
||||
url = url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape)
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
fallback()
|
||||
ServerImage(
|
||||
url = url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
overlay()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||
* primary action button. Per the design system, the button uses
|
||||
* `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
|
||||
fun ErrorRetry(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
retryLabel: String = "Retry",
|
||||
) {
|
||||
val actionColors = LocalActionColors.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.TriangleAlert,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = actionColors.primary,
|
||||
contentColor = actionColors.onAction,
|
||||
),
|
||||
) {
|
||||
Text(retryLabel)
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillParentMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.TriangleAlert,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
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 = message,
|
||||
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = actionColors.primary,
|
||||
contentColor = actionColors.onAction,
|
||||
),
|
||||
) {
|
||||
Text(retryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
|
||||
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||
private const val FALLBACK_FADE_MS = 220
|
||||
|
||||
/**
|
||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||
* is blank or unresolvable.
|
||||
* is blank/unresolvable, while the image is still loading, and when the
|
||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||
* which the "You might like" row hits often).
|
||||
*
|
||||
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||
* provides one, so a screen-level overlay can wait for artwork to land
|
||||
* instead of guessing with a fixed delay.
|
||||
*/
|
||||
@Composable
|
||||
fun ServerImage(
|
||||
@@ -22,12 +47,43 @@ fun ServerImage(
|
||||
val resolved = resolveServerUrl(url)
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// Empty counts as loading: it's the pre-request state, so treating it
|
||||
// as settled would let a screen overlay lower before Coil even starts.
|
||||
val loading = state is AsyncImagePainter.State.Empty ||
|
||||
state is AsyncImagePainter.State.Loading
|
||||
val tracker = LocalArtSettleTracker.current
|
||||
DisposableEffect(tracker, loading) {
|
||||
if (loading) tracker?.begin()
|
||||
// Balanced by construction: the effect re-runs when `loading` flips
|
||||
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||
// the composition mid-load (scrolled away).
|
||||
onDispose { if (loading) tracker?.end() }
|
||||
}
|
||||
val fallbackAlpha by animateFloatAsState(
|
||||
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||
animationSpec = tween(FALLBACK_FADE_MS),
|
||||
label = "art-fallback",
|
||||
)
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (fallbackAlpha > 0f) {
|
||||
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val APK_FILENAME = "minstrel-update.apk"
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
|
||||
/**
|
||||
* Downloads the server-bundled APK and hands it to Android's package
|
||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
||||
* side that the Flutter MethodChannel delegated to.
|
||||
* Downloads the server-bundled APK and installs it over ourselves.
|
||||
*
|
||||
* The download goes through the shared [OkHttpClient] so it inherits
|
||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||
* cache dir, exposed to the system installer via the app's
|
||||
* FileProvider content:// URI.
|
||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||
* [android.content.pm.PackageInstaller] session.
|
||||
*
|
||||
* On Android O+ the user must have granted "install unknown apps"
|
||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
||||
* opens the relevant settings screen.
|
||||
* opens the relevant settings screen. That grant is still required with
|
||||
* the session API — silent *updates* don't imply silent *permission*.
|
||||
*/
|
||||
@Singleton
|
||||
class ApkInstaller @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val session: SelfUpdateSession,
|
||||
) {
|
||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
||||
fun launchInstall(apk: File) {
|
||||
val uri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apk,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, APK_MIME)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
/**
|
||||
* Install [apk] over ourselves, suspending until the platform decides.
|
||||
*
|
||||
* Note for callers: on a successful silent install this never returns —
|
||||
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||
*/
|
||||
suspend fun install(apk: File): InstallOutcome = session.run(apk)
|
||||
|
||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||
fun requestInstallPermission() {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
/**
|
||||
* Terminal verdict from the platform on a self-update install (#2438).
|
||||
*
|
||||
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
|
||||
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
|
||||
* reports back, so "declined" and "failed" stop looking identical.
|
||||
*/
|
||||
sealed interface InstallOutcome {
|
||||
/**
|
||||
* The platform completed the install.
|
||||
*
|
||||
* Rarely observed on a self-update: our process is replaced the moment the
|
||||
* new APK lands, so the coroutine awaiting this usually dies before it
|
||||
* resumes. Modelled anyway — silently relying on being killed would make
|
||||
* the success path invisible to anyone reading this.
|
||||
*/
|
||||
data object Installed : InstallOutcome
|
||||
|
||||
/** The user declined the platform's confirm dialog. Not an error. */
|
||||
data object Cancelled : InstallOutcome
|
||||
|
||||
/** The platform refused. [reason] is its own message, where it gave one. */
|
||||
data class Failed(val reason: String?) : InstallOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||
* Settings About card.
|
||||
*
|
||||
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
|
||||
* state because the install half was fire-and-forget and took no time from our
|
||||
* side; now that we await the platform's verdict, collapsing them would leave
|
||||
* the UI claiming "Downloading…" through an install that can sit on a confirm
|
||||
* dialog indefinitely.
|
||||
*/
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||
|
||||
/** True while an install is underway and a second tap should do nothing. */
|
||||
fun InstallStage.isBusy(): Boolean =
|
||||
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||
|
||||
/**
|
||||
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||
* be a lie with a red tint.
|
||||
*/
|
||||
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||
*
|
||||
* Lives beside the outcome rather than in either UI package because two
|
||||
* separate screens surface the same verdicts and must not drift — the same
|
||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||
* outside the UI layer.
|
||||
*/
|
||||
fun InstallOutcome.message(): String? = when (this) {
|
||||
InstallOutcome.Installed -> null
|
||||
InstallOutcome.Cancelled -> "Update cancelled."
|
||||
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
|
||||
?: "Couldn't install update."
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val STAGED_APK_NAME = "minstrel-update"
|
||||
|
||||
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
|
||||
private const val WRITE_FROM_START = 0L
|
||||
|
||||
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
|
||||
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
|
||||
|
||||
/**
|
||||
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
|
||||
*
|
||||
* Split from [ApkInstaller] because the two halves are different work — one
|
||||
* speaks HTTP, the other speaks to the package manager — and the session half
|
||||
* carries a receiver, a PendingIntent and version-gated params that would
|
||||
* crowd the downloader out of its own file.
|
||||
*
|
||||
* ## Why a session, rather than the ACTION_VIEW intent this replaced
|
||||
*
|
||||
* Two reasons, and the second is the one that matters to users.
|
||||
*
|
||||
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
|
||||
* URI and hoped. It could not report an outcome, so a failed install and a
|
||||
* user who declined looked identical — see [InstallOutcome].
|
||||
*
|
||||
* More importantly, a session is where the platform lets a self-updater say it
|
||||
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
|
||||
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
|
||||
* manifest permission, is the sanctioned way to update with **no dialog at
|
||||
* all**. The platform grants that when all of: the installer opts in (here),
|
||||
* the installed app targets API 29+ (we're on 36), the installer holds the
|
||||
* permission (we do), and the target is the installer itself or something it
|
||||
* first installed (we are updating ourselves). All four hold.
|
||||
*
|
||||
* ## What is deliberately absent
|
||||
*
|
||||
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
|
||||
* a self-updater and it is not: ownership can only be claimed on **initial**
|
||||
* installation — setting it on an update is documented as a no-op — and it also
|
||||
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
|
||||
* stores claiming the apps they install, not for an app updating itself.
|
||||
*/
|
||||
@Singleton
|
||||
class SelfUpdateSession @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
/**
|
||||
* Stage [apk] and hand it to the platform, suspending until a terminal
|
||||
* verdict arrives.
|
||||
*
|
||||
* Never returns on the happy path when the install is silent: the platform
|
||||
* replaces this process the moment the new APK lands, so the coroutine dies
|
||||
* rather than resuming. Callers must treat that as success, not a hang.
|
||||
*/
|
||||
suspend fun run(apk: File): InstallOutcome {
|
||||
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
|
||||
return staged.fold(
|
||||
onSuccess = { sessionId -> awaitCommit(sessionId) },
|
||||
onFailure = { InstallOutcome.Failed(it.message) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Open a session, stream the APK in, return the session id. */
|
||||
private fun stage(apk: File): Int {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val sessionId = installer.createSession(newParams())
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
|
||||
apk.inputStream().use { source -> source.copyTo(sink) }
|
||||
// fsync before the session closes: the platform validates the
|
||||
// staged bytes at commit, and buffered tail bytes read as a
|
||||
// truncated APK.
|
||||
session.fsync(sink)
|
||||
}
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
|
||||
// runs on assembleRelease, and NewApi is easier for it to reason about when
|
||||
// the guarded call has a named receiver instead of an implicit one.
|
||||
private fun newParams(): PackageInstaller.SessionParams {
|
||||
val params = PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||
)
|
||||
params.setAppPackageName(context.packageName)
|
||||
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The whole point of this class. Pre-S there is no such API, so the
|
||||
// confirm dialog is unavoidable there — degrade, don't fail.
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the session and wait for the platform to report back.
|
||||
*
|
||||
* A pending-user-action status is *not* terminal — the platform is asking us
|
||||
* to show its dialog, and the real verdict arrives in a second broadcast
|
||||
* once the user decides. So the receiver stays registered across it.
|
||||
*/
|
||||
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(unused: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE,
|
||||
)
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
confirmWithUser(intent)
|
||||
} else {
|
||||
context.unregisterReceiver(this)
|
||||
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
|
||||
}
|
||||
}
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(RESULT_ACTION),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
continuation.invokeOnCancellation {
|
||||
// Stop listening, but deliberately do NOT abandon the session.
|
||||
// Cancellation here means our caller's scope died — the user
|
||||
// navigated away, or the VM cleared — and by this point the
|
||||
// session is already committed. The user asked for this install;
|
||||
// killing it because nobody is watching the banner any more
|
||||
// would be the wrong reading of their intent.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
runCatching {
|
||||
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
|
||||
}.onFailure { error ->
|
||||
// Resuming normally means invokeOnCancellation never fires, so
|
||||
// clean up the staged session here or it sits until it expires.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
runCatching { installer.abandonSession(sessionId) }
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(InstallOutcome.Failed(error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultSender(sessionId: Int): IntentSender {
|
||||
// Scoped to our own package so the broadcast can't be answered elsewhere.
|
||||
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The platform writes its status extras into this intent, so it has
|
||||
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
|
||||
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
}
|
||||
// Session id as the request code keeps concurrent sessions from
|
||||
// colliding on FLAG_UPDATE_CURRENT.
|
||||
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the platform's own confirm dialog, which arrives as an extra.
|
||||
*
|
||||
* The system-app check is not ceremony. Below API 34 a dynamically
|
||||
* registered receiver cannot declare itself unexported, so another app on
|
||||
* the device can broadcast [RESULT_ACTION] at us — and calling
|
||||
* `startActivity` on an attacker-supplied extra would hand it whatever we
|
||||
* can reach. The genuine confirm activity belongs to the platform
|
||||
* installer, so demanding a system component costs the real path nothing.
|
||||
*/
|
||||
private fun confirmWithUser(result: Intent) {
|
||||
val pending = IntentCompat.getParcelableExtra(
|
||||
result,
|
||||
Intent.EXTRA_INTENT,
|
||||
Intent::class.java,
|
||||
) ?: return
|
||||
if (isPlatformActivity(pending)) {
|
||||
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPlatformActivity(intent: Intent): Boolean {
|
||||
val flags = intent.resolveActivityInfo(context.packageManager, 0)
|
||||
?.applicationInfo
|
||||
?.flags
|
||||
?: 0
|
||||
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
return (flags and systemFlags) != 0
|
||||
}
|
||||
|
||||
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||
else -> InstallOutcome.Failed(message)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
/**
|
||||
* Shell-level soft banner that nudges an available update. Renders
|
||||
@@ -79,7 +81,7 @@ private fun BannerBody(
|
||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||
if (stage == InstallStage.DOWNLOADING) {
|
||||
if (stage.isBusy()) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||
// Downloading and installing are separate words because they're now
|
||||
// separate waits — the install half suspends on the platform, which
|
||||
// may be sitting on a confirm dialog.
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
|
||||
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Install lifecycle for the banner's Install button. */
|
||||
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
|
||||
|
||||
data class UpdateBannerUiState(
|
||||
val info: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
||||
|
||||
/**
|
||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||
* and runs the download → system-install handoff via [ApkInstaller],
|
||||
* mirroring the About card's flow (route to "install unknown apps"
|
||||
* settings first when the permission is missing).
|
||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||
* the About card's flow (route to "install unknown apps" settings first
|
||||
* when the permission is missing).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateBannerViewModel @Inject constructor(
|
||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val installState = MutableStateFlow(IdleInstall)
|
||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||
|
||||
val uiState: StateFlow<UpdateBannerUiState> =
|
||||
combine(controller.available, installState) { info, install ->
|
||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
fun dismiss(version: String) = controller.dismiss(version)
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
||||
if (installState.value.stage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
installState.value = InstallSnapshot(
|
||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
installState.value = IdleInstall
|
||||
}
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// Await the platform's verdict rather than firing an intent and
|
||||
// assuming it worked. On a silent install this suspends until
|
||||
// the process is replaced, so the line below is only reached
|
||||
// when the install did NOT simply succeed.
|
||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||
val outcome = installer.install(apk)
|
||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||
|
||||
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon (API 26+). Before this the app shipped legacy bitmaps only,
|
||||
so modern launchers letterboxed the square instead of masking it to the
|
||||
device's icon shape. The foreground PNGs are drawn on a 108dp canvas with
|
||||
the mark inside the 66dp safe zone, so no mask can clip it. -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |