Compare commits
204
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4727fc49 | ||
|
|
fd27819cdd | ||
|
|
37b396a7e4 | ||
|
|
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 | ||
|
|
93365cb555 | ||
|
|
2c5c477a0d | ||
|
|
eeabdf1f2c | ||
|
|
f7278f2417 | ||
|
|
9a166f9032 | ||
|
|
b3d6785543 | ||
|
|
773275e916 | ||
|
|
066616e196 | ||
|
|
3e258507bb | ||
|
|
fd7d6dac4c | ||
|
|
d6290a3ef0 | ||
|
|
9550d8daaf | ||
|
|
4d5af3599e | ||
|
|
db393bbe65 | ||
|
|
1a7515e6ea | ||
|
|
6c26ba807e | ||
|
|
c7adf2c87a | ||
|
|
7b7bd0c3e8 | ||
|
|
26c368c35e | ||
|
|
36786defd1 | ||
|
|
8019537b02 | ||
|
|
3f240bc777 | ||
|
|
e41d603c12 | ||
|
|
1cc9553d1f | ||
|
|
a62f07bd3a | ||
|
|
c2f9bbaff4 | ||
|
|
abc225e12a | ||
|
|
3c646c6974 | ||
|
|
aff346c731 | ||
|
|
13b3fca949 | ||
|
|
6e0d0e5723 | ||
|
|
752906b054 | ||
|
|
fdd14ef04c | ||
|
|
9a57dc4bec | ||
|
|
4ecb1680bf | ||
|
|
d2a22e49e3 | ||
|
|
f167ddfbfb | ||
|
|
653ed95f14 | ||
|
|
b2c6f6f0e9 | ||
|
|
962b4dbc8c | ||
|
|
aa4089118e | ||
|
|
dabca3ad24 | ||
|
|
7c791dc8e4 | ||
|
|
8f29cc7414 | ||
|
|
c8b21aa76e | ||
|
|
483804fc9e | ||
|
|
12a8cfccb5 | ||
|
|
e358a92cf4 | ||
|
|
63b25e65ad | ||
|
|
a766b7193f | ||
|
|
e95138d412 | ||
|
|
7426f6c718 | ||
|
|
e994aae613 | ||
|
|
c39a9ca18f | ||
|
|
06e155abb6 | ||
|
|
6b11208e5a | ||
|
|
41da012494 | ||
|
|
8a3104443c | ||
|
|
5f5ab69da6 | ||
|
|
5386ae870f | ||
|
|
d7b011e52f | ||
|
|
11466e1525 | ||
|
|
e185b36138 | ||
|
|
31d8c30dfe | ||
|
|
69ccd7b25d | ||
|
|
8b77c6be97 | ||
|
|
c33ef18a50 | ||
|
|
4c9450c117 | ||
|
|
b467cb7532 | ||
|
|
c78bbb7ba5 |
@@ -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
|
||||
|
||||
@@ -2,8 +2,8 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (no APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||
@@ -20,6 +20,14 @@ name: release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
@@ -123,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
|
||||
@@ -181,6 +196,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -221,11 +242,23 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Main
|
||||
# pushes skip and the image ships with empty client/ (the
|
||||
# /api/client/version endpoint then returns 404 by design).
|
||||
# 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/
|
||||
@@ -247,6 +280,42 @@ jobs:
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+13
@@ -1754,6 +1754,19 @@
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="36" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "sync_metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cursor",
|
||||
"columnName": "cursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "lastSyncAt",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortName",
|
||||
"columnName": "sortName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistThumbPath",
|
||||
"columnName": "artistThumbPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistFanartPath",
|
||||
"columnName": "artistFanartPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortTitle",
|
||||
"columnName": "sortTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releaseDate",
|
||||
"columnName": "releaseDate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMs",
|
||||
"columnName": "durationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackNumber",
|
||||
"columnName": "trackNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filePath",
|
||||
"columnName": "filePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileFormat",
|
||||
"columnName": "fileFormat",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_likes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"userId",
|
||||
"entityType",
|
||||
"entityId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPublic",
|
||||
"columnName": "isPublic",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackCount",
|
||||
"columnName": "trackCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationSec",
|
||||
"columnName": "durationSec",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "systemVariant",
|
||||
"columnName": "systemVariant",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlist_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"playlistId",
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_quarantine_mine",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reason",
|
||||
"columnName": "reason",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackTitle",
|
||||
"columnName": "trackTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackDurationMs",
|
||||
"columnName": "trackDurationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumTitle",
|
||||
"columnName": "albumTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCoverArtPath",
|
||||
"columnName": "albumCoverArtPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistName",
|
||||
"columnName": "artistName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "audio_cache_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cachedAt",
|
||||
"columnName": "cachedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastPlayedAt",
|
||||
"columnName": "lastPlayedAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_mutations",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "kind",
|
||||
"columnName": "kind",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "payload",
|
||||
"columnName": "payload",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastAttemptAt",
|
||||
"columnName": "lastAttemptAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attempts",
|
||||
"columnName": "attempts",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_resume_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_home_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "section",
|
||||
"columnName": "section",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"section",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_history_snapshot",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "auth_session",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionCookie",
|
||||
"columnName": "sessionCookie",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userJson",
|
||||
"columnName": "userJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "themeMode",
|
||||
"columnName": "themeMode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "clientId",
|
||||
"columnName": "clientId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cacheSettingsJson",
|
||||
"columnName": "cacheSettingsJson",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
@@ -41,7 +41,7 @@ import javax.inject.Inject
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: ServerHealthController
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: ServerHealthController,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
|
||||
@@ -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
|
||||
@@ -19,8 +22,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,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(),
|
||||
@@ -115,21 +121,13 @@ class MinstrelApplication :
|
||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — VersionCheckController's
|
||||
* init block starts a 5-min poll loop against /healthz so the
|
||||
* shell-level VersionTooOldBanner can surface min_client_version
|
||||
* mismatches without waiting for the next user-driven request.
|
||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||
* /healthz poll loop + the device-link collector + the reachability state
|
||||
* machine, and is the single authority on the tri-state ServerHealth
|
||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — ServerHealthController combines
|
||||
* ConnectivityObserver + VersionCheckController.reachable into the
|
||||
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
|
||||
* so the StateFlow needs an active subscriber from launch onward; the
|
||||
* @Inject keeps the singleton alive and the flow collecting.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
@@ -164,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() {
|
||||
@@ -215,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()
|
||||
}
|
||||
|
||||
+48
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
|
||||
+41
@@ -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>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
||||
@GET("api/artists/{id}/tracks")
|
||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||
|
||||
@GET("api/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): List<TrackWire>
|
||||
|
||||
@GET("api/albums/{id}")
|
||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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"
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
|
||||
+24
-3
@@ -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()
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||
|
||||
|
||||
+25
-1
@@ -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)",
|
||||
|
||||
+35
@@ -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()
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
/**
|
||||
* Projection: how many of a playlist's member tracks are resident in the audio
|
||||
* cache index. Backs the offline "fully cached" greying — a playlist is fully
|
||||
* available offline when [cachedCount] reaches its `trackCount`.
|
||||
*/
|
||||
data class PlaylistCachedCount(
|
||||
val playlistId: String,
|
||||
val cachedCount: Int,
|
||||
)
|
||||
+7
@@ -36,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,
|
||||
)
|
||||
|
||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
||||
* - "rediscover_artists"
|
||||
* - "most_played_tracks"
|
||||
* - "last_played_artists"
|
||||
* - "you_might_like_albums"
|
||||
* - "you_might_like_artists"
|
||||
*
|
||||
* New `section` values need no schema change — the column stores the
|
||||
* string verbatim and the DAO keys on it.
|
||||
*
|
||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||
* to the right per-entity endpoint when a tile is rendered.
|
||||
|
||||
Vendored
+28
@@ -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,
|
||||
)
|
||||
+74
@@ -28,6 +28,19 @@ object MutationKind {
|
||||
const val PLAY_OFFLINE: String = "play_offline"
|
||||
const val REQUEST_CANCEL: String = "request_cancel"
|
||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||
|
||||
// Durable close-by-id for a play whose server row is already open
|
||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||
// a *whole* play when no server row exists. Using close-by-id on
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
|
||||
// #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"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,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(
|
||||
@@ -167,6 +199,17 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Durable close-by-id for an already-open server play row. Background
|
||||
* path only (no user hint) — same as [enqueuePlayOffline].
|
||||
*/
|
||||
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAY_ENDED,
|
||||
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||
@@ -174,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
|
||||
@@ -221,6 +279,22 @@ 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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
||||
* `POST /api/events` with type=play_ended, closing the already-open server
|
||||
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
||||
* idempotent (re-ending updates the same row and skips a duplicate
|
||||
* skip_events insert), so a lost-response retry can't duplicate history.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayEndedPayload(
|
||||
val playEventId: String,
|
||||
val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+174
-115
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
||||
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||
@@ -10,7 +12,11 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
@@ -19,12 +25,16 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,25 +44,24 @@ import javax.inject.Singleton
|
||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||
* deletes the row on 2xx success.
|
||||
* resolves each to an [Outcome]:
|
||||
* - SENT — server accepted (2xx) → delete the row.
|
||||
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
|
||||
* payload) → delete the row so it can't wedge the queue forever.
|
||||
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
|
||||
* attempts, retry on the next trigger.
|
||||
*
|
||||
* Triggered by:
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
||||
* init block to subscribe)
|
||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
||||
* value (sign-in, app launch with persisted cookie)
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||
* start with a persisted cookie)
|
||||
* - NetworkStatusController recovering to Healthy (server reachable again
|
||||
* mid-session — covers offline→online without needing a cold start)
|
||||
*
|
||||
* Single in-flight via the `mutex` — back-to-back triggers collapse
|
||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
||||
* sign-in + app-open triggers cover the common offline → online
|
||||
* transition (operator unlocks phone, opens app).
|
||||
*
|
||||
* Failed rows stay in the queue with their `attempts` and
|
||||
* `lastAttemptAt` updated. They get another chance on the next
|
||||
* trigger; no exponential backoff or max-attempts yet — the rows are
|
||||
* small (low single-digit count for typical use) so retry-forever is
|
||||
* cheap.
|
||||
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||
* typical use, and permanent failures are now dropped rather than retried
|
||||
* forever.
|
||||
*/
|
||||
@Singleton
|
||||
class MutationReplayer @Inject constructor(
|
||||
@@ -60,6 +69,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val json: Json,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val likesApi: LikesApi = retrofit.create()
|
||||
@@ -72,6 +82,8 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private enum class Outcome { SENT, DROP, RETRY }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -79,6 +91,16 @@ class MutationReplayer @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
// Server-reachable-again: replay queued writes the moment the
|
||||
// health signal recovers, so an offline→online transition mid-
|
||||
// session doesn't wait for the next cold start / re-auth.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||
@@ -93,69 +115,81 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// 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) {
|
||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
||||
if (sent) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
} else {
|
||||
dao.recordAttempt(row.id, Clock.System.now())
|
||||
continue
|
||||
}
|
||||
when (outcomeFor(row)) {
|
||||
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
||||
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
|
||||
} catch (@Suppress("SwallowedException") e: SerializationException) {
|
||||
// Corrupt / schema-incompatible payload — permanent, drop it.
|
||||
Outcome.DROP
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
|
||||
// Transport / unknown — leave queued for the next pass.
|
||||
Outcome.RETRY
|
||||
}
|
||||
|
||||
/** 4xx are permanent except the two "retry me" statuses. */
|
||||
private fun isPermanent(code: Int): Boolean =
|
||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||
|
||||
/**
|
||||
* Returns true if the server accepted the call, false on any
|
||||
* failure (transport, server error, decode). The replayer treats
|
||||
* false as "leave the row for next pass" — it doesn't try to
|
||||
* distinguish permanent rejections from transient ones; a row
|
||||
* that consistently fails will accumulate attempt count and
|
||||
* lastAttemptAt for diagnostics.
|
||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||
* by [outcomeFor] — dispatchers no longer swallow.
|
||||
*/
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
else -> {
|
||||
// Unknown kind — drop the row by claiming success so a
|
||||
// stale schema entry can't wedge the queue forever.
|
||||
true
|
||||
}
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||
else -> return true // unknown variant — drop.
|
||||
else -> return Outcome.DROP
|
||||
}
|
||||
return try {
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCreate(payload: String): Boolean {
|
||||
private suspend fun dispatchRequestCreate(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||
val body = CreateRequestBody(
|
||||
discoverApi.createRequest(
|
||||
CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
@@ -163,56 +197,28 @@ class MutationReplayer @Inject constructor(
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
),
|
||||
)
|
||||
return try {
|
||||
discoverApi.createRequest(body)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||
return try {
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
@@ -220,18 +226,11 @@ class MutationReplayer @Inject constructor(
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||
return try {
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
@@ -239,33 +238,49 @@ class MutationReplayer @Inject constructor(
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
deviceClass = decoded.deviceClass,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayEnded(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
|
||||
eventsApi.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = decoded.playEventId,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||
return try {
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
||||
/**
|
||||
* 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)
|
||||
return try {
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
@@ -274,12 +289,56 @@ class MutationReplayer @Inject constructor(
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
+42
-1
@@ -1,7 +1,10 @@
|
||||
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
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
@@ -15,9 +18,15 @@ 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
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -50,10 +59,26 @@ class SyncController @Inject constructor(
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val authStore: AuthStore,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: SyncApi = retrofit.create()
|
||||
|
||||
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* Human copy for the most recent sync failure; null after any clean
|
||||
* pass. Lets the Library screen distinguish "empty because the first
|
||||
* sync failed" (show error + retry) from "genuinely empty library"
|
||||
* (show welcome copy) — a failed sync over a populated cache stays
|
||||
* silent, since the cached content is still the better surface.
|
||||
*/
|
||||
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -61,11 +86,27 @@ class SyncController @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
// Re-sync when the server becomes reachable again mid-session, so a
|
||||
// delta missed while offline lands without waiting for a cold start.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ import javax.inject.Singleton
|
||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which
|
||||
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
|
||||
* has its own failure hysteresis), not this coarse device-link signal.
|
||||
*
|
||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
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
|
||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||
import com.fabledsword.minstrel.update.api.HealthzResponse
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
|
||||
private const val POLL_DEGRADED_MS = 20_000L
|
||||
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
|
||||
/**
|
||||
* THE single authority on network/server reachability. Absorbs the former
|
||||
* VersionCheckController (the /healthz poll + version parsing) and
|
||||
* ServerHealthController (the tri-state derive) into one signal-driven unit so
|
||||
* the app has one place that answers "can we reach Minstrel?" — not three
|
||||
* half-systems reporting differently.
|
||||
*
|
||||
* Inputs (all funnel through a single-consumer intent channel for thread
|
||||
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
||||
* and OkHttp threads):
|
||||
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
||||
* immediate probe so recovery is near-instant (fixes the sticky banner).
|
||||
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
* - 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.
|
||||
*
|
||||
* Constructed at launch via the construct-the-singleton trick in
|
||||
* [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class NetworkStatusController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) : DefaultLifecycleObserver {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
|
||||
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
|
||||
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
|
||||
|
||||
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
|
||||
|
||||
private sealed interface Intent {
|
||||
data class Link(val up: Boolean) : Intent
|
||||
data class Probe(val resp: HealthzResponse?) : Intent
|
||||
object OpSuccess : Intent
|
||||
object OpFailure : Intent
|
||||
}
|
||||
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
intents.trySend(Intent.Link(up))
|
||||
if (up) probeOnce()
|
||||
}
|
||||
}
|
||||
scope.launch { pollLoop() }
|
||||
}
|
||||
|
||||
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
|
||||
fun reportSuccess() {
|
||||
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
|
||||
}
|
||||
|
||||
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
|
||||
fun reportFailure() {
|
||||
if (stateInternal.value == ServerHealth.Offline) return
|
||||
intents.trySend(Intent.OpFailure)
|
||||
}
|
||||
|
||||
/** One-shot recheck for pull-to-refresh and the banner. */
|
||||
fun recheck() {
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
when (intent) {
|
||||
is Intent.Link -> machine.onLinkChange(intent.up)
|
||||
is Intent.Probe -> applyProbe(intent.resp, now)
|
||||
Intent.OpSuccess -> machine.onSuccess()
|
||||
Intent.OpFailure -> {
|
||||
machine.onOpFailure(now)
|
||||
// Arbitrate off the reducer thread: awaiting a stalled
|
||||
// /healthz here would block a concurrent self-proving
|
||||
// success from snapping us straight back to Healthy.
|
||||
scope.launch { probeOnce() }
|
||||
}
|
||||
}
|
||||
emit(machine.health())
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyProbe(resp: HealthzResponse?, now: Long) {
|
||||
if (resp == null) {
|
||||
machine.onProbeFailure(now)
|
||||
} else {
|
||||
machine.onSuccess()
|
||||
versionInternal.value = versionResultFor(resp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(next: ServerHealth) {
|
||||
if (stateInternal.value != next) {
|
||||
Timber.w("NetworkStatus -> %s", next)
|
||||
stateInternal.value = next
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollLoop() {
|
||||
probeOnce()
|
||||
while (true) {
|
||||
val interval =
|
||||
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
|
||||
else POLL_DEGRADED_MS
|
||||
delay(interval)
|
||||
probeOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeOnce(force: Boolean = false) {
|
||||
// Startup guard: don't poll the localhost placeholder before AuthStore
|
||||
// hydrates the real base URL — that false failure used to flash the banner.
|
||||
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
||||
lastProbeAtMs.set(now)
|
||||
val resp = runCatching { api.check() }.getOrNull()
|
||||
intents.trySend(Intent.Probe(resp))
|
||||
}
|
||||
|
||||
private fun versionResultFor(resp: HealthzResponse): VersionResult {
|
||||
val min = resp.minClientVersion
|
||||
return when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||
else -> VersionResult.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
|
||||
* composables (TrackRow gating, write-affordance disabling) branch on health
|
||||
* without re-injecting the controller. Defaults to Healthy so previews/tests
|
||||
* don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* 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-
|
||||
* testable. [NetworkStatusController] wires real time + signals around it.
|
||||
*
|
||||
* Reachability (independent of the device link):
|
||||
* - Reachable last evidence says the server answered.
|
||||
* - Unstable a probe failed; arbitration/escalation pending.
|
||||
* - Unreachable corroborated or sustained failure.
|
||||
*
|
||||
* [health] folds the device link over that: no link → Offline; otherwise the
|
||||
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
||||
*
|
||||
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
||||
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
||||
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
||||
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
||||
* sustained-time backstop.
|
||||
*/
|
||||
class ReachabilityMachine {
|
||||
|
||||
private enum class Reachability { Reachable, Unstable, Unreachable }
|
||||
|
||||
private var linkUp = true
|
||||
private var reachability = Reachability.Reachable
|
||||
private var failureStreakStartMs: Long? = null
|
||||
private val recentOpFailures = ArrayDeque<Long>()
|
||||
|
||||
fun onLinkChange(up: Boolean) {
|
||||
linkUp = up
|
||||
// Link transitions don't reset reachability — a restored link keeps the
|
||||
// last-known server reachability until a fresh probe/op result arrives.
|
||||
if (!up) recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
||||
fun onSuccess() {
|
||||
reachability = Reachability.Reachable
|
||||
failureStreakStartMs = null
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
||||
fun onProbeFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
if (reachability == Reachability.Reachable) {
|
||||
reachability = Reachability.Unstable
|
||||
failureStreakStartMs = nowMs
|
||||
}
|
||||
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
||||
reachability = Reachability.Unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fun health(): ServerHealth = when {
|
||||
!linkUp -> ServerHealth.Offline
|
||||
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
||||
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
||||
else -> ServerHealth.ServerDown
|
||||
}
|
||||
|
||||
private fun shouldEscalate(nowMs: Long): Boolean {
|
||||
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
||||
val sustained =
|
||||
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
||||
return corroborated || sustained
|
||||
}
|
||||
|
||||
private fun pruneOpFailures(nowMs: Long) {
|
||||
while (recentOpFailures.isNotEmpty() &&
|
||||
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
||||
) {
|
||||
recentOpFailures.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
|
||||
import dagger.Lazy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val HEALTHZ_PATH = "/healthz"
|
||||
|
||||
/**
|
||||
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
|
||||
* self-proving proof the server is reachable → reportSuccess(); a transport
|
||||
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
|
||||
* arbitration.
|
||||
*
|
||||
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
|
||||
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
|
||||
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
|
||||
* loading must NOT be read as "our server is reachable" — only sentinel-host
|
||||
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
|
||||
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
|
||||
*
|
||||
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
|
||||
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
|
||||
* interceptor. By the time a request flows through, the controller singleton is
|
||||
* already constructed (construct-the-singleton trick in MinstrelApplication).
|
||||
*/
|
||||
@Singleton
|
||||
class ReachabilityReportingInterceptor @Inject constructor(
|
||||
private val networkStatus: Lazy<NetworkStatusController>,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val isMinstrel = request.url.host == PLACEHOLDER_HOST
|
||||
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
|
||||
if (!isMinstrel || isHealthz) return chain.proceed(request)
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) networkStatus.get().reportSuccess()
|
||||
response
|
||||
} catch (e: IOException) {
|
||||
networkStatus.get().reportFailure()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
/**
|
||||
* The single reachability signal every consumer branches on.
|
||||
*
|
||||
* - [Healthy] link up, /healthz ok — normal network behavior.
|
||||
* - [Unstable] link up, a recent failure with arbitration pending —
|
||||
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
||||
* anti-flicker intent of commit 5c0db429 while still warning
|
||||
* the user that something is flaky.
|
||||
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
||||
* gate to cache-only.
|
||||
* - [Offline] no device link at all — gate to cache-only.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tri-state server-reachability signal that downstream consumers can branch
|
||||
* on to decide whether to hit the network, gate writes, or fall back to
|
||||
* cache-only behavior.
|
||||
*
|
||||
* Composed from two existing signals -- this controller doesn't poll its own
|
||||
* endpoint:
|
||||
*
|
||||
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
|
||||
* answers only "is there an active INTERNET-capable network link" (NOT
|
||||
* VALIDATED -- a WAN-validation flicker must not read as offline when
|
||||
* the LAN server is reachable).
|
||||
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
|
||||
* succeed. This is the authority on whether *Minstrel* is reachable;
|
||||
* it has its own failure hysteresis. Distinguishes "device has a link
|
||||
* but our server is down" from "no network at all."
|
||||
*
|
||||
* `version too old` is intentionally *not* folded in here -- it's a separate
|
||||
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
|
||||
* real cause.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Offline, ServerDown }
|
||||
|
||||
@Singleton
|
||||
class ServerHealthController @Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
versionCheck: VersionCheckController,
|
||||
) {
|
||||
val state: StateFlow<ServerHealth> = combine(
|
||||
connectivity.online,
|
||||
versionCheck.reachable,
|
||||
) { online, serverReachable ->
|
||||
when {
|
||||
!online -> ServerHealth.Offline
|
||||
!serverReachable -> ServerHealth.ServerDown
|
||||
else -> ServerHealth.Healthy
|
||||
}
|
||||
}
|
||||
// Transition log -- the signal had no instrumentation, which is why a
|
||||
// false-offline (WAN flicker flipping playback to "Source error") was
|
||||
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
|
||||
.distinctUntilChanged()
|
||||
.onEach { Timber.w("ServerHealth -> %s", it) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root from the
|
||||
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
|
||||
* affordance disabling) branch on health without each ViewModel re-
|
||||
* injecting the controller. Defaults to Healthy so unwrapped previews
|
||||
* and tests don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+70
-32
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.composables.icons.lucide.CloudOff
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||
* composable. Keeps the banner pure-presentation.
|
||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
health: ServerHealthController,
|
||||
networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the user can't reach the server.
|
||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||
* health returns to [ServerHealth.Healthy].
|
||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||
* window, and a brief "Back online" confirmation when health recovers so
|
||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||
var showBackOnline by remember { mutableStateOf(false) }
|
||||
var wasDown by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(health) {
|
||||
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
|
||||
if (health == ServerHealth.Healthy && wasDown) {
|
||||
// try/finally so a mid-delay cancellation (health flips again) can't
|
||||
// orphan the flag and leave "Back online" stuck on screen.
|
||||
try {
|
||||
showBackOnline = true
|
||||
delay(BACK_ONLINE_FLASH_MS)
|
||||
} finally {
|
||||
showBackOnline = false
|
||||
}
|
||||
}
|
||||
wasDown = down
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = health != ServerHealth.Healthy,
|
||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
BannerContent(health = health, backOnline = showBackOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
when {
|
||||
backOnline -> {
|
||||
background = scheme.secondaryContainer
|
||||
foreground = scheme.onSecondaryContainer
|
||||
}
|
||||
health == ServerHealth.Unstable -> {
|
||||
background = scheme.surfaceVariant
|
||||
foreground = scheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
background = scheme.errorContainer
|
||||
foreground = scheme.onErrorContainer
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.background(background)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
||||
Text(
|
||||
text = when (health) {
|
||||
ServerHealth.Offline ->
|
||||
"No connection — check Wi-Fi or mobile data."
|
||||
ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
ServerHealth.Healthy -> ""
|
||||
},
|
||||
text = bannerText(health, backOnline),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
color = foreground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
||||
backOnline -> "Back online."
|
||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
||||
health == ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
+328
@@ -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
|
||||
}
|
||||
}
|
||||
+118
@@ -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
|
||||
}
|
||||
}
|
||||
+81
@@ -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) {
|
||||
)
|
||||
}
|
||||
}
|
||||
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,11 +36,15 @@ 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
|
||||
// count past detekt's 11 default — accessor density, not complexity.
|
||||
@Suppress("TooManyFunctions")
|
||||
class HomeRepository @Inject constructor(
|
||||
private val homeIndexDao: CachedHomeIndexDao,
|
||||
private val metadataProvider: MetadataProvider,
|
||||
@@ -76,68 +82,104 @@ class HomeRepository @Inject constructor(
|
||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||
|
||||
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
||||
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
||||
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/** 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)
|
||||
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
||||
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 ->
|
||||
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]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,5 +190,18 @@ class HomeRepository @Inject constructor(
|
||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||
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
|
||||
@@ -54,6 +64,8 @@ import com.composables.icons.lucide.History
|
||||
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
|
||||
@@ -74,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
|
||||
@@ -106,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(
|
||||
@@ -115,6 +154,8 @@ data class HomeSections(
|
||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
) {
|
||||
val isAllEmpty: Boolean
|
||||
get() = playlists.isEmpty() &&
|
||||
@@ -122,7 +163,9 @@ data class HomeSections(
|
||||
rediscoverAlbums.isEmpty() &&
|
||||
rediscoverArtists.isEmpty() &&
|
||||
mostPlayedTracks.isEmpty() &&
|
||||
lastPlayedArtists.isEmpty()
|
||||
lastPlayedArtists.isEmpty() &&
|
||||
youMightLikeAlbums.isEmpty() &&
|
||||
youMightLikeArtists.isEmpty()
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -134,7 +177,8 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||
@@ -142,23 +186,42 @@ class HomeViewModel @Inject constructor(
|
||||
/** System-playlist build status for the Home placeholder cards. */
|
||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||
|
||||
/** True when the device has no usable internet — gates the offline-pool cards. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/**
|
||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||
*/
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
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
|
||||
@@ -171,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}")
|
||||
}
|
||||
@@ -208,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)}",
|
||||
)
|
||||
}
|
||||
@@ -233,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)}",
|
||||
)
|
||||
}
|
||||
@@ -258,39 +321,81 @@ 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>> =
|
||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
/**
|
||||
* Five home-section flows merged into the sections struct without
|
||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||
* Splitting it like this keeps each combine call inside the
|
||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||
* The core five section flows merged into the sections struct. Split
|
||||
* out so each combine call stays inside the coroutines built-in arity
|
||||
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||
* across heterogeneous types would need. [observeHomeSections] then
|
||||
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||
* playlists flow.
|
||||
*/
|
||||
private fun observeHomeSections() = combine(
|
||||
private fun observeCoreSections() = combine(
|
||||
homeRepository.observeRecentlyAddedAlbums(),
|
||||
homeRepository.observeRediscoverAlbums(),
|
||||
homeRepository.observeRediscoverArtists(),
|
||||
@@ -306,10 +411,144 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeHomeSections() = combine(
|
||||
observeCoreSections(),
|
||||
homeRepository.observeYouMightLikeAlbums(),
|
||||
homeRepository.observeYouMightLikeArtists(),
|
||||
) { core, ymlAlbums, ymlArtists ->
|
||||
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,19 +577,47 @@ 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.
|
||||
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()
|
||||
@@ -360,9 +627,10 @@ fun HomeScreen(
|
||||
"settings, then come back here for system playlists " +
|
||||
"and recommendations.",
|
||||
)
|
||||
is UiState.Error -> EmptyState(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load home",
|
||||
body = s.message,
|
||||
message = s.message,
|
||||
onRetry = { viewModel.retry() },
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
sections = s.data,
|
||||
@@ -380,6 +648,45 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,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,
|
||||
@@ -458,6 +767,26 @@ 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,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||
rediscoverSection(
|
||||
albums = sections.rediscoverAlbums,
|
||||
@@ -473,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,
|
||||
@@ -662,6 +1020,61 @@ private fun RediscoverBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "You might like" — in-library albums/artists predicted from the user's
|
||||
* taste that they don't actively spin (server section, daily-built, cold-
|
||||
* start gated). Sits directly under the system-playlists row. Same
|
||||
* albums-then-artists block shape as Rediscover; the artist sub-row drops
|
||||
* its title when an album sub-row precedes it so the two read as one section.
|
||||
*/
|
||||
private fun LazyListScope.youMightLikeSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
item {
|
||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
||||
YouMightLikeBlock(
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
} else {
|
||||
EmptySection(
|
||||
title = "You might like",
|
||||
body = "We're still learning your taste — keep listening and " +
|
||||
"picks you might like will show up here.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YouMightLikeBlock(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
if (albums.isNotEmpty()) {
|
||||
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
val title = if (albums.isEmpty()) "You might like" else ""
|
||||
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumsRow(
|
||||
title: String,
|
||||
@@ -687,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(
|
||||
@@ -701,18 +1115,19 @@ private fun PlaylistsRow(
|
||||
icon = iconForPool(item.kind),
|
||||
onClick = { onPlayPool(item.kind) },
|
||||
)
|
||||
is PlaylistRowItem.Real -> PlaylistCard(
|
||||
is PlaylistRowItem.Real -> {
|
||||
// Greyed offline when the tile needs the live server or
|
||||
// isn't fully cached — dimmed but still tappable into the
|
||||
// detail to shuffle whatever subset is cached.
|
||||
val greyed = offline && item.playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
// Match Flutter: refreshable system playlists need
|
||||
// the live server endpoints, so disable their play
|
||||
// overlay when offline (user can still tap into the
|
||||
// detail and shuffle all from cache). User playlists
|
||||
// play from cache + survive offline.
|
||||
playEnabled = item.playlist.trackCount > 0 &&
|
||||
!(offline && item.playlist.refreshable),
|
||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||
label = item.label,
|
||||
variant = item.variant,
|
||||
@@ -728,7 +1143,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
||||
}
|
||||
|
||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||
private sealed interface PlaylistRowItem {
|
||||
internal sealed interface PlaylistRowItem {
|
||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||
@@ -741,55 +1156,105 @@ enum class OfflinePoolKind(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* 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, not guaranteed singletons. Finally user-owned
|
||||
* playlists.
|
||||
* 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. 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
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
*/
|
||||
private fun buildPlaylistsRow(
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
if (offline) {
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||
if (!offline) return buildOnlineRow(owned, status)
|
||||
val out = mutableListOf<PlaylistRowItem>(
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
|
||||
)
|
||||
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
|
||||
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
): List<PlaylistRowItem> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||
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(3)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Secondary system kinds in server-registry order. Only included
|
||||
// when actually generated — these depend on library shape (Deep
|
||||
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||
// so a missing one means "not enough data" rather than "still
|
||||
// building".
|
||||
// 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) }
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||
*
|
||||
* Online: every generated mix as a real card; when none exist yet, a few
|
||||
* placeholders so the building / seed-needed state stays visible.
|
||||
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||
* an empty list — hiding the whole section — when nothing is cached.
|
||||
*/
|
||||
internal fun buildSongsLikeRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||
return when {
|
||||
offline -> {
|
||||
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||
}
|
||||
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
* Songs-like is excluded here too — it renders in its own row via
|
||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += it }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
s.inFlight -> "building"
|
||||
s.lastError != null -> "failed"
|
||||
@@ -797,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
|
||||
|
||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||
* strip. Network-only — non-critical UI, callers swallow failures
|
||||
* and render nothing rather than blocking the screen.
|
||||
*/
|
||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||
api.getSimilarArtists(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||
* panel. Network-only; tapping a row plays this exact list as a
|
||||
* queue, so no persistence is needed.
|
||||
*/
|
||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||
|
||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.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 {
|
||||
|
||||
+118
-14
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items as lazyItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -98,26 +107,39 @@ 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 -> {
|
||||
val artistLiked by viewModel.artistLiked
|
||||
.collectAsStateWithLifecycle()
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSuccessBody(
|
||||
state: ArtistDetailUiState.Success,
|
||||
viewModel: ArtistDetailViewModel,
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
detail = s.detail,
|
||||
state = state,
|
||||
artistLiked = artistLiked,
|
||||
playingTrackId = playerState.currentTrack?.id,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onTopTrackClick = viewModel::playTopTracks,
|
||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
@@ -127,12 +149,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistBody(
|
||||
detail: ArtistDetailRef,
|
||||
state: ArtistDetailUiState.Success,
|
||||
artistLiked: Boolean,
|
||||
playingTrackId: String?,
|
||||
onPlay: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onTopTrackClick: (Int) -> Unit,
|
||||
onSimilarArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val detail = state.detail
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -149,6 +175,23 @@ private fun ArtistBody(
|
||||
onToggleLike = onToggleLike,
|
||||
)
|
||||
}
|
||||
if (state.topTracks.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
TopTracksPanel(
|
||||
tracks = state.topTracks,
|
||||
playingTrackId = playingTrackId,
|
||||
onTrackClick = onTopTrackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similarArtists.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SimilarArtistsStrip(
|
||||
artists = state.similarArtists,
|
||||
onArtistClick = onSimilarArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (detail.albums.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyAlbumsHint()
|
||||
@@ -161,6 +204,67 @@ private fun ArtistBody(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTracksPanel(
|
||||
tracks: List<TrackRef>,
|
||||
playingTrackId: String?,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Top tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
tracks.forEachIndexed { index, track ->
|
||||
TopTrackRow(
|
||||
track = track,
|
||||
nowPlaying = track.id == playingTrackId,
|
||||
onClick = { onTrackClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTrackRow(
|
||||
track: TrackRef,
|
||||
nowPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
artist = track.artistName,
|
||||
trackId = track.id,
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarArtistsStrip(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Similar artists") {
|
||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeader(
|
||||
artist: ArtistRef,
|
||||
|
||||
+65
-1
@@ -5,10 +5,13 @@ 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
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -23,13 +26,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Success(
|
||||
val detail: ArtistDetailRef,
|
||||
val similarArtists: List<ArtistRef> = emptyList(),
|
||||
val topTracks: List<TrackRef> = emptyList(),
|
||||
) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
|
||||
@@ -39,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
private val likes: LikesRepository,
|
||||
private val player: PlayerController,
|
||||
private val seedCache: DetailSeedCache,
|
||||
networkStatus: NetworkStatusController,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -63,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() {
|
||||
@@ -82,6 +104,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
loadSecondarySections()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
@@ -89,6 +112,31 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||
* and folds them into the live Success state. Each failure is
|
||||
* swallowed (logged only) — these sections render absent rather than
|
||||
* blocking, so the core artist view never depends on them.
|
||||
*/
|
||||
private suspend fun loadSecondarySections() {
|
||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||
val current = internal.value
|
||||
if (current is ArtistDetailUiState.Success) {
|
||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
@@ -118,4 +166,20 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
||||
* tracks are themselves the playable queue (mirrors the web client's
|
||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
||||
* list is already in the Success state.
|
||||
*/
|
||||
fun playTopTracks(index: Int) {
|
||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
||||
if (tracks.isEmpty()) return
|
||||
player.setQueue(
|
||||
tracks = tracks,
|
||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
||||
source = "artist-top:$artistId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.observeArtists(),
|
||||
repository.observeAlbums(),
|
||||
) { artists, albums ->
|
||||
if (artists.isEmpty() && albums.isEmpty()) {
|
||||
UiState.Empty
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
||||
val trackCount: Int = 0,
|
||||
val coverUrl: String = "",
|
||||
val ownerUsername: String = "",
|
||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
||||
val fullyCached: Boolean = false,
|
||||
) {
|
||||
val isSystem: Boolean get() = systemVariant != null
|
||||
|
||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
||||
* Callers combine this with the current offline state to grey the tile.
|
||||
*/
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -15,10 +19,16 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayStartedRequest(
|
||||
val type: String = "play_started",
|
||||
// Discriminator MUST stay on the wire. The app's Json has
|
||||
// encodeDefaults=false, so without this a `type` left at its
|
||||
// default is omitted entirely and the server 400s the event as
|
||||
// "unknown event type" — silently dropping every native play.
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("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
|
||||
@@ -28,24 +38,26 @@ data class PlayStartedResponse(
|
||||
|
||||
@Serializable
|
||||
data class PlayEndedRequest(
|
||||
val type: String = "play_ended",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaySkippedRequest(
|
||||
val type: String = "play_skipped",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("position_ms") val positionMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayOfflineRequest(
|
||||
val type: String = "play_offline",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -15,8 +15,12 @@ import kotlinx.serialization.Serializable
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - youMightLikeAlbums → album
|
||||
* - youMightLikeArtists → artist
|
||||
*
|
||||
* All slices default to empty so missing keys don't throw on decode.
|
||||
* All slices default to empty so missing keys don't throw on decode —
|
||||
* which also keeps the client safe against older servers that don't
|
||||
* emit the you-might-like sections yet.
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeIndexWire(
|
||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -18,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 ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||
*
|
||||
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||
* drop threshold and playback falls back to a phone that *also* has no
|
||||
* network — silence, while the renderer was streaming fine the whole time.
|
||||
*
|
||||
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||
* the moment it drops or the user switches back to the phone.
|
||||
*
|
||||
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||
* synchronized, so they are safe to call from any thread.
|
||||
*/
|
||||
class CastNetworkLock(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val wifiLock: WifiManager.WifiLock? =
|
||||
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? =
|
||||
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
@Synchronized
|
||||
fun acquire() {
|
||||
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||
Timber.w(
|
||||
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release() {
|
||||
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||
Timber.w("CastNetworkLock released")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||
|
||||
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||
// the radio fully awake.
|
||||
fun wifiLockMode(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
-43
@@ -10,14 +10,20 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.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
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -39,21 +45,31 @@ import timber.log.Timber
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||
* factory wraps that callback into a SharedFlow consumed by the
|
||||
* NowPlaying surface as a snackbar.
|
||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||
* two while the renderer is perfectly reachable, so commands retry on
|
||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* 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,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -107,6 +123,20 @@ class MinstrelForwardingPlayer(
|
||||
scope.launch {
|
||||
holder.active.collect { active -> onActiveChanged(active) }
|
||||
}
|
||||
scope.launch {
|
||||
// When the phone's own network recovers, reconcile a held cast
|
||||
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||
// the renderer kept playing, so one round-trip re-confirms it and
|
||||
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||
// while a route is active matters.
|
||||
var prev = networkStatus.state.value
|
||||
networkStatus.state.collect { next ->
|
||||
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
@@ -172,13 +202,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
remoteState.setPlayIntent(true)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.play() }
|
||||
runCatching { retryTransport { active.avTransport.play() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +224,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
remoteState.setPlayIntent(false)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.pause() }
|
||||
runCatching { retryTransport { active.avTransport.pause() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +254,8 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,11 +285,13 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +314,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.next() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +337,28 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.previous() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -385,11 +436,34 @@ class MinstrelForwardingPlayer(
|
||||
super.release()
|
||||
}
|
||||
|
||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||
pollJob?.cancel()
|
||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||
remoteState.applyError(t)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
/**
|
||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||
* first socket I/O for a second or two; a retry lets the command land once
|
||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||
* renderer answered and rejected the action) is NOT retried -- the device
|
||||
* is alive and retrying won't change its verdict.
|
||||
*/
|
||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||
|
||||
/**
|
||||
* A transport SOAP command failed even after retries. Deliberately does
|
||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||
* WiFi power-save can fail one command while the renderer is perfectly
|
||||
* reachable; dropping on a single command falsely kicked playback back to
|
||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||
* truly gone, the poll loop trips the drop on its own.
|
||||
*/
|
||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||
if (t is SoapFaultException) {
|
||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||
} else {
|
||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
@@ -402,6 +476,11 @@ class MinstrelForwardingPlayer(
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||
// without this the radio power-saves on a locked screen and the
|
||||
// poll below starves -- see [CastNetworkLock].
|
||||
castNetworkLock.acquire()
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
@@ -411,21 +490,27 @@ class MinstrelForwardingPlayer(
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
var networkDropSuppressed = false
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
networkDropSuppressed = false
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
select<Unit> {
|
||||
@@ -435,20 +520,34 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||
* autonomously and is almost certainly still playing; meanwhile a local
|
||||
* fallback couldn't reach the server either, so dropping would only blast
|
||||
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||
* the route and keep polling -- a successful poll once the network returns
|
||||
* reconciles to the renderer's real state. Clear the failure streak so
|
||||
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||
* the rationale logs once per outage, not every tick.
|
||||
*/
|
||||
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||
if (!alreadySuppressed) {
|
||||
Timber.w(
|
||||
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||
active.routeName, networkStatus.state.value,
|
||||
)
|
||||
}
|
||||
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [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()
|
||||
@@ -464,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 -> {
|
||||
@@ -488,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
|
||||
@@ -513,5 +684,39 @@ class MinstrelForwardingPlayer(
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
// Transport commands retry transient IO failures so a single WiFi
|
||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||
* answered and rejected the action -- propagates immediately: the device is
|
||||
* alive, so retrying won't change its verdict. Extracted from
|
||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||
* playback to local audio).
|
||||
*/
|
||||
internal suspend fun <T> retryTransientIo(
|
||||
attempts: Int,
|
||||
backoffMs: Long,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (io: IOException) {
|
||||
attempt += 1
|
||||
if (attempt >= attempts) throw io
|
||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* DataSource wrapper that fails the network read immediately when
|
||||
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
|
||||
* [NetworkStatusController] reports a gating state. CacheDataSource only
|
||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||
@@ -22,10 +22,23 @@ import java.io.InterruptedIOException
|
||||
*/
|
||||
class OfflineGatedDataSource(
|
||||
private val delegate: DataSource,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource {
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
gateOnHealth()
|
||||
return try {
|
||||
val opened = delegate.open(dataSpec)
|
||||
health.reportSuccess() // bytes flowing from the server == reachable
|
||||
opened
|
||||
} catch (e: IOException) {
|
||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast-fail before touching the network when the server can't be reached. */
|
||||
private fun gateOnHealth() {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
@@ -33,9 +46,9 @@ class OfflineGatedDataSource(
|
||||
ServerHealth.ServerDown -> throw OfflineException(
|
||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||
)
|
||||
ServerHealth.Healthy -> Unit
|
||||
// Unstable is non-gating: still try the network. Healthy too.
|
||||
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
|
||||
}
|
||||
return delegate.open(dataSpec)
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
@@ -49,7 +62,7 @@ class OfflineGatedDataSource(
|
||||
|
||||
class OfflineGatedDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
||||
private var curLastPositionMs: Long = 0
|
||||
private var curDurationMs: Long = 0
|
||||
|
||||
// Whether the currently-tracked play is actively playing (vs paused).
|
||||
// onStop reads this: a backgrounded play that's still playing is left
|
||||
// to the live path (the foreground service keeps us alive), while a
|
||||
// paused one is durably closed so a kill can't orphan its open row.
|
||||
private var curPlaying: Boolean = false
|
||||
|
||||
// Server play_event_id when the live play_started succeeded;
|
||||
// null if start failed/offline — then the close is captured into
|
||||
// the offline mutation queue instead of a live ended/skipped.
|
||||
private var openPlayEventId: String? = null
|
||||
|
||||
// True from beginTrack until startLive's play_started resolves. When a
|
||||
// close races an in-flight start (id still null), the server row is
|
||||
// being created but we've lost its id — we must NOT also enqueue an
|
||||
// offline play (that would duplicate); the server auto-closes the
|
||||
// orphan on the next play_started instead.
|
||||
private var startInFlight: Boolean = false
|
||||
|
||||
private var prevTrackId: String? = null
|
||||
|
||||
init {
|
||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Track-change → close the prior tracked play.
|
||||
if (trackId != prevTrackId && curTrackId != null) {
|
||||
closeCurrent(viaOffline = false)
|
||||
closeCurrent()
|
||||
}
|
||||
|
||||
// Entered playing for a new track → begin tracking it.
|
||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
if (curTrackId != null && trackId == curTrackId) {
|
||||
curLastPositionMs = positionMs
|
||||
if (durationMs > 0) curDurationMs = durationMs
|
||||
curPlaying = playing
|
||||
}
|
||||
|
||||
prevTrackId = trackId
|
||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
||||
curSource = source
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = durationMs
|
||||
curPlaying = true
|
||||
openPlayEventId = null
|
||||
startInFlight = true
|
||||
startLive(trackId = trackId, source = source)
|
||||
}
|
||||
|
||||
private fun startLive(trackId: String, source: String?) {
|
||||
val cid = resolveClientId() ?: return
|
||||
val cid = resolveClientId()
|
||||
if (cid == null) {
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val response = api.playStarted(
|
||||
@@ -134,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
trackId = trackId,
|
||||
clientId = cid,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
if (curTrackId == trackId) {
|
||||
@@ -144,47 +166,61 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Offline / flaky — openPlayEventId stays null; close
|
||||
// path will enqueue the completed play for replay.
|
||||
} finally {
|
||||
startInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeCurrent(viaOffline: Boolean) {
|
||||
/**
|
||||
* Closes the tracked play. Routes by whether the server already has an
|
||||
* open row, NOT by caller context — this is what keeps background /
|
||||
* track-change / failure paths from each minting a second row:
|
||||
* - open server row (id) → close it by id; on failure enqueue a durable
|
||||
* close-by-id (never an offline play).
|
||||
* - start in flight (no id yet) → leave it; the server auto-closes the
|
||||
* orphan on the next play_started. Enqueuing offline here would
|
||||
* duplicate the in-flight row.
|
||||
* - no server row at all → record the whole play offline.
|
||||
*
|
||||
* Always uses the actual position played; the server's skip rule
|
||||
* (50%/30s defaults) classifies was_skipped from it.
|
||||
*/
|
||||
private fun closeCurrent() {
|
||||
val trackId = curTrackId
|
||||
val startedAt = curStartedAt
|
||||
if (trackId == null || startedAt == null) {
|
||||
resetCurrent()
|
||||
return
|
||||
}
|
||||
// Always send the actual position played to; the server's
|
||||
// skip rule (50%/30s defaults, configurable) classifies
|
||||
// was_skipped from this. If the track ran to completion the
|
||||
// last observed position is ~= duration, which gives ratio ~1
|
||||
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
||||
// rounding the prior shape did is no longer needed — the
|
||||
// server's threshold is the source of truth.
|
||||
val durationMs = curLastPositionMs
|
||||
val source = curSource
|
||||
val id = openPlayEventId
|
||||
val inFlight = startInFlight
|
||||
when {
|
||||
id != null -> closeById(id, durationMs)
|
||||
inFlight -> Unit
|
||||
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
resetCurrent()
|
||||
}
|
||||
|
||||
if (!viaOffline && id != null) {
|
||||
private fun closeById(id: String, durationMs: Long) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = id,
|
||||
durationPlayedMs = durationMs,
|
||||
),
|
||||
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
// Durable close-by-id (idempotent server-side) — survives a
|
||||
// process kill without orphaning the open row or duplicating.
|
||||
mutationQueue.enqueuePlayEnded(
|
||||
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
resetCurrent()
|
||||
}
|
||||
|
||||
private fun resetCurrent() {
|
||||
@@ -193,7 +229,9 @@ class PlayEventsReporter @Inject constructor(
|
||||
curSource = null
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = 0
|
||||
curPlaying = false
|
||||
openPlayEventId = null
|
||||
startInFlight = false
|
||||
}
|
||||
|
||||
private fun enqueueOffline(
|
||||
@@ -211,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
atIso = startedAt.toString(),
|
||||
durationPlayedMs = durationPlayedMs,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -231,9 +270,24 @@ 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) {
|
||||
if (curTrackId != null) {
|
||||
closeCurrent(viaOffline = true)
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
// app-scope uiState collector — alive, so the live state machine
|
||||
// closes the play accurately on the next real track change. Closing
|
||||
// here (the old always-offline behavior) raced the collector's
|
||||
// re-begin and minted a duplicate, skip-misclassified row on every
|
||||
// screen-lock. Only durably close a PAUSED play, where no further
|
||||
// progress will happen and a process kill would otherwise orphan the
|
||||
// open server row. See docs/superpowers/2026-06-11 contract audit.
|
||||
if (curTrackId != null && !curPlaying) {
|
||||
closeCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// A track failing to play is ambiguous (dead server vs. one bad
|
||||
// file) — let the controller arbitrate via /healthz. No-op when
|
||||
// already Offline; cheap otherwise.
|
||||
networkStatus.reportFailure()
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
|
||||
@@ -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
|
||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
||||
controller.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||
* playing our queue, we sync the local cursor to the renderer's current
|
||||
* track/position so skip/seek and the UI map onto its live session — the
|
||||
* renderer keeps the audio, the local player stays paused. Unlike
|
||||
* [seekToIndex] this issues no play().
|
||||
*/
|
||||
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||
val controller = mediaController ?: return
|
||||
if (index !in queueRefs.indices) return
|
||||
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with [tracks] starting at [initialIndex].
|
||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||
@@ -264,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
|
||||
@@ -337,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,
|
||||
@@ -353,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() {
|
||||
@@ -403,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(
|
||||
@@ -506,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) {
|
||||
@@ -525,28 +649,30 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven primary path: clear pending the moment Sonos's reported
|
||||
@@ -562,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
|
||||
@@ -749,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
|
||||
|
||||
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||
* - Poll failures -> recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Success resets it.
|
||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||
* Failed transport commands no longer drop the route -- they retry and
|
||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// The operator's last explicit transport intent (play=true, pause=false),
|
||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
lastPlayIntent = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
/** Action the idle-revert collector should take on a player-state change. */
|
||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||
|
||||
/**
|
||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||
* otherwise IGNORE.
|
||||
*/
|
||||
internal fun idleRevertAction(
|
||||
upnpEngaged: Boolean,
|
||||
isPlaying: Boolean,
|
||||
armed: Boolean,
|
||||
): IdleRevertAction = when {
|
||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||
armed -> IdleRevertAction.CANCEL
|
||||
else -> IdleRevertAction.IGNORE
|
||||
}
|
||||
+202
-5
@@ -15,10 +15,12 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -147,6 +150,12 @@ class OutputPickerController @Inject constructor(
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||
// See idleRevertAction for the arm/cancel decision.
|
||||
private var idleRevertJob: Job? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
@@ -155,7 +164,8 @@ class OutputPickerController @Inject constructor(
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||
// the sole drop arbiter; a failed transport command no longer drops),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
@@ -163,6 +173,79 @@ class OutputPickerController @Inject constructor(
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
scope.launch { observeIdleRevertWhileUpnp() }
|
||||
scope.launch { observeSelectedRouteDisappearance() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||
* to it. Without this the selection could pin to a ghost route, so a later
|
||||
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||
*/
|
||||
private suspend fun observeSelectedRouteDisappearance() {
|
||||
upnpDiscovery.routes.collect { routes ->
|
||||
val selected = selectedUpnpRouteIdInternal.value
|
||||
val present = routes.any { it.id == selected }
|
||||
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||
if (selected != null && !present && !casting) {
|
||||
Timber.w(
|
||||
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||
selected,
|
||||
)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||
*/
|
||||
private suspend fun observeIdleRevertWhileUpnp() {
|
||||
playerController.uiState.collect { state ->
|
||||
val engaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
val armed = idleRevertJob?.isActive == true
|
||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||
IdleRevertAction.ARM ->
|
||||
idleRevertJob = scope.launch {
|
||||
delay(IDLE_REVERT_MS)
|
||||
revertToPhoneOnIdle()
|
||||
}
|
||||
IdleRevertAction.CANCEL -> {
|
||||
idleRevertJob?.cancel()
|
||||
idleRevertJob = null
|
||||
}
|
||||
IdleRevertAction.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||
* seek local ExoPlayer to the last remote position. No play(): the
|
||||
* operator was paused, so we revert routing silently. Guarded by the
|
||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||
*/
|
||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
idleRevertJob = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,16 +448,19 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||
* threshold or SOAP exception). Captures the last remote position +
|
||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
* consecutive-failure threshold tripping). Captures the last remote
|
||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
// Resume on the operator's last play-intent, not the observed remote
|
||||
// state: after a pause, isPlaying is false, but a failed play() that
|
||||
// triggered this drop means the operator DID ask to play -- honor it.
|
||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
@@ -482,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||
// If the renderer is already playing this exact queue (a "stray"
|
||||
// session we got disconnected from), attach to it in place instead of
|
||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||
return@withLock
|
||||
}
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
// silently while target is set but active is null -- the user's premature
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
@@ -497,6 +590,9 @@ class OutputPickerController @Inject constructor(
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
// the standing intent is "play" -- a later drop should resume local.
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
@@ -505,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's current position + transport state, validated as an
|
||||
* adoptable continuation of our local queue: same track id at the same
|
||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||
*/
|
||||
private data class AdoptPlan(
|
||||
val localIndex: Int,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val trackUri: String,
|
||||
val trackNumber: Int,
|
||||
val playing: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Attach to a renderer that is *already* playing our queue (a session the
|
||||
* phone got disconnected from but the speaker kept streaming) instead of
|
||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||
* mid-playback on the same track id at the same queue index, syncs the
|
||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||
* the caller does the full clear+reload) when the renderer isn't playing
|
||||
* our queue. The local player is already paused by the caller.
|
||||
*/
|
||||
private suspend fun tryAdoptRunningSession(
|
||||
transport: AVTransportClient,
|
||||
rendering: RenderingControlClient?,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||
Timber.w(
|
||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||
route.name, plan.trackNumber, plan.positionMs,
|
||||
)
|
||||
// active is still null here, so this aligns the *local* cursor without
|
||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||
// reconciles to the renderer's advancing position from here.
|
||||
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = plan.positionMs,
|
||||
durationMs = plan.durationMs,
|
||||
trackUri = plan.trackUri,
|
||||
trackNumber = plan.trackNumber,
|
||||
)
|
||||
remoteState.setPlayIntent(plan.playing)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = route.id,
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun probeAdoptable(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): AdoptPlan? {
|
||||
val probe = runCatching {
|
||||
transport.getTransportInfo() to transport.getPositionInfo()
|
||||
}.getOrElse {
|
||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||
return null
|
||||
}
|
||||
val (info, pos) = probe
|
||||
val idx0 = pos.track - 1
|
||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||
val midPlayback =
|
||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||
val aligned = midPlayback && playingId != null &&
|
||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||
return if (aligned) {
|
||||
AdoptPlan(
|
||||
localIndex = idx0,
|
||||
positionMs = pos.relTimeMs,
|
||||
durationMs = pos.trackDurationMs,
|
||||
trackUri = pos.trackUri,
|
||||
trackNumber = pos.track,
|
||||
playing = info.state == TransportState.PLAYING,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||
private fun trackIdFromStreamUrl(url: String): String? {
|
||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||
val i = segments.indexOf("tracks")
|
||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||
}
|
||||
|
||||
private suspend fun loadQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
@@ -640,5 +832,10 @@ class OutputPickerController @Inject constructor(
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||
// a later "tap play" do nothing.
|
||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-2
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -17,6 +18,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -45,9 +48,24 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||
// dispatcher stay shared with the app client.
|
||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||
// (and can't pin a stale selection) instead of lingering for the process
|
||||
// lifetime -- upsert only ever added before.
|
||||
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
@@ -68,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
appScope.launch {
|
||||
val scanStartMs = System.currentTimeMillis()
|
||||
ssdp.requestActiveScan()
|
||||
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||
// description fetch), then drop any route that didn't re-announce.
|
||||
// Pruning is tied to picker-open scans only -- no background timer,
|
||||
// so route rows never flicker while the user isn't looking.
|
||||
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||
pruneRoutesNotSeenSince(scanStartMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||
*/
|
||||
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||
val before = routesInternal.value
|
||||
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||
if (survivors.size == before.size) return
|
||||
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||
removed.forEach { lastSeenMs.remove(it) }
|
||||
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||
routesInternal.value = survivors
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +133,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(okHttp, route.name),
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
@@ -105,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
@@ -225,6 +268,16 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
|
||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||
|
||||
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
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),
|
||||
private fun JumpToCurrentPill(
|
||||
visible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
+31
-5
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
||||
import java.net.HttpURLConnection
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
||||
|
||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||
|
||||
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||
/**
|
||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
||||
* greying without each consumer re-querying the cache index.
|
||||
*/
|
||||
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
combine(
|
||||
playlistDao.observeAll(),
|
||||
playlistDao.observeCachedCounts(),
|
||||
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
|
||||
|
||||
/** User-owned playlists only (systemVariant IS NULL). */
|
||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||
@@ -110,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,
|
||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
||||
|
||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
||||
|
||||
/**
|
||||
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
|
||||
* from the cache-index counts. A playlist is fully cached when it has tracks
|
||||
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
|
||||
* of the Flow so the predicate is unit-testable without a Room database.
|
||||
*/
|
||||
internal fun mergePlaylistsWithCache(
|
||||
rows: List<CachedPlaylistEntity>,
|
||||
counts: List<PlaylistCachedCount>,
|
||||
): List<PlaylistRef> {
|
||||
val cachedById = counts.associate { it.playlistId to it.cachedCount }
|
||||
return rows.map { row ->
|
||||
val cached = cachedById[row.id] ?: 0
|
||||
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
PlaylistRef(
|
||||
id = id,
|
||||
|
||||
+85
-30
@@ -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
|
||||
|
||||
+39
-18
@@ -27,7 +27,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
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
|
||||
@@ -39,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
|
||||
@@ -46,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
|
||||
@@ -66,7 +72,7 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
connectivity: ConnectivityObserver,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
@@ -74,15 +80,18 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
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
|
||||
@@ -92,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. */
|
||||
@@ -109,12 +125,13 @@ 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)
|
||||
@@ -156,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,
|
||||
@@ -191,14 +209,15 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
// Greyed offline when it needs the live server or isn't fully
|
||||
// cached — dimmed but still tappable into the detail.
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||
// disable when offline. Empty mixes show a snackbar after tap.
|
||||
playEnabled = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable),
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,11 +226,13 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0,
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||
* uses it for system playlists in offline mode (their server-side
|
||||
* shuffle endpoint is unreachable) and for empty playlists.
|
||||
*
|
||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaylistCard(
|
||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onPlay: (suspend () -> Unit)? = null,
|
||||
playEnabled: Boolean = true,
|
||||
greyed: Boolean = false,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
||||
PlaylistCardCover(
|
||||
playlist = playlist,
|
||||
onPlay = onPlay,
|
||||
playEnabled = playEnabled,
|
||||
playEnabled = playEnabled && !greyed,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
private const val PILL_BG_ALPHA = 0.85f
|
||||
private const val GREYED_ALPHA = 0.45f
|
||||
|
||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user