Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
57d2299180 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
fa7ea41ccf | ||
|
|
e1e591b520 | ||
|
|
324059b2bd | ||
|
|
eec59193fa | ||
|
|
ca4832e620 | ||
|
|
cf0d37bf8e | ||
|
|
799dab029a | ||
|
|
7315e37c15 | ||
|
|
4f9b083eec | ||
|
|
f17356560d | ||
|
|
18a61f1065 | ||
|
|
6e39471a70 | ||
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
1138d75a45 | ||
|
|
cf7b489fec | ||
|
|
8483948f23 | ||
|
|
0cea82984c | ||
|
|
3acac985cd | ||
|
|
d3b40342b4 | ||
|
|
4f99b42844 | ||
|
|
5044e7a055 | ||
|
|
7d45a4e5c7 | ||
|
|
fa0827f668 | ||
|
|
52d53e0044 | ||
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d | ||
|
|
47de7be472 | ||
|
|
659554df0e | ||
|
|
2ecdd46a2b | ||
|
|
41fe76b90c | ||
|
|
6912dadf2b | ||
|
|
304e06acc8 | ||
|
|
235839b696 | ||
|
|
4f69c230c4 | ||
|
|
5749f48b4a | ||
|
|
f0c08e7326 | ||
|
|
199fec2058 | ||
|
|
65dd132b3d | ||
|
|
40384cc05e | ||
|
|
40056d2e9a | ||
|
|
2b3be8311a | ||
|
|
c30511e71b | ||
|
|
797ed1f5ad | ||
|
|
96c2eb6afb | ||
|
|
cce928e584 | ||
|
|
7d18a3c808 | ||
|
|
c34753f5b0 | ||
|
|
fbfd5550ff | ||
|
|
d6ee5a304d | ||
|
|
d497c57d6c | ||
|
|
2e92ba498c | ||
|
|
c1d143cf4a | ||
|
|
cb0af5efd3 | ||
|
|
20a76f4b39 | ||
|
|
7226dab9ff | ||
|
|
772a52b23e | ||
|
|
725ddca950 | ||
|
|
29e1e7c64c | ||
|
|
9ad4343c76 | ||
|
|
0d0a8f46b1 | ||
|
|
9e02878b61 | ||
|
|
48f288e2e5 | ||
|
|
2be07ef271 | ||
|
|
a670840114 | ||
|
|
5faa57634b | ||
|
|
fb4431207d | ||
|
|
60533073ad | ||
|
|
4b150a277e | ||
|
|
d145fee35d | ||
|
|
7628330f72 | ||
|
|
09102dc615 | ||
|
|
611715154b | ||
|
|
392454b249 | ||
|
|
cdfc79e6ab | ||
|
|
79f2d79a2e | ||
|
|
96b15b75e6 | ||
|
|
23a82fb38d | ||
|
|
782f152d37 | ||
|
|
bffa5b28bd | ||
|
|
8a58f07237 | ||
|
|
4d42e298dd | ||
|
|
4ed831d9c3 | ||
|
|
0de2437689 | ||
|
|
f4f4df7708 | ||
|
|
e932ab438c | ||
|
|
d05264ff80 | ||
|
|
a23e2e36ca | ||
|
|
5a80a1e460 | ||
|
|
16f76ea707 | ||
|
|
d4cc177db4 | ||
|
|
27766ae063 | ||
|
|
335d782215 | ||
|
|
2a8de82a17 | ||
|
|
096a3c0b15 | ||
|
|
a251dce7e3 | ||
|
|
97e0e88483 | ||
|
|
938dae7163 | ||
|
|
ad37937949 | ||
|
|
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 |
@@ -80,10 +80,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload debug APK
|
- name: Upload debug APK
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||||
uses: actions/upload-artifact@v3
|
# 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:
|
with:
|
||||||
name: minstrel-android-debug-${{ github.sha }}
|
name: minstrel-android-debug-${{ github.sha }}
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
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.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to main → :main and :latest (no APK bundled)
|
# push to main → :main and :latest (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (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)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||||
@@ -20,6 +20,14 @@ name: release
|
|||||||
# happens in the same android-release job, so the Release-page download
|
# happens in the same android-release job, so the Release-page download
|
||||||
# link and the in-image bundled APK are both populated atomically.
|
# link and the in-image bundled APK are both populated atomically.
|
||||||
#
|
#
|
||||||
|
# :latest always carries an APK. Because every main push also moves
|
||||||
|
# :latest (not just tags), a main build with no APK would silently strip
|
||||||
|
# the in-app update channel off :latest until the next release. So on
|
||||||
|
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||||
|
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||||
|
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||||
|
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||||
|
#
|
||||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||||
# lives in android.yml and runs independently on every push.
|
# lives in android.yml and runs independently on every push.
|
||||||
|
|
||||||
@@ -123,12 +131,19 @@ jobs:
|
|||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||||
uses: actions/upload-artifact@v3
|
# 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:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.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
|
- name: Attach APK to gitea Release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -181,6 +196,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags so non-tag :latest builds can resolve the
|
||||||
|
# latest release tag's commit count and reconstruct the bundled
|
||||||
|
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Detect buildable project
|
- name: Detect buildable project
|
||||||
id: guard
|
id: guard
|
||||||
@@ -221,11 +242,23 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag pushes only — android-release just produced this. Main
|
# Tag pushes only — android-release just produced this. Non-tag
|
||||||
# pushes skip and the image ships with empty client/ (the
|
# builds take the "Bundle latest release APK" path below instead.
|
||||||
# /api/client/version endpoint then returns 404 by design).
|
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
# 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:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: client/
|
path: client/
|
||||||
@@ -247,6 +280,42 @@ jobs:
|
|||||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
|
- name: Bundle latest release APK (non-tag :latest builds)
|
||||||
|
# Main pushes don't build an APK, but they DO move :latest — so
|
||||||
|
# without this the in-app update channel would vanish from :latest
|
||||||
|
# until the next tag. Pull the most-recent release's signed APK and
|
||||||
|
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||||
|
# TAG) — identical to android-release's formula) so the version
|
||||||
|
# sidecar the server hands clients matches the installed build.
|
||||||
|
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||||
|
# version — if no release / APK asset / tag-count can be resolved.
|
||||||
|
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||||
|
if [ -z "${REL_JSON}" ]; then
|
||||||
|
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||||
|
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||||
|
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||||
|
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||||
|
if [ -z "${COUNT}" ]; then
|
||||||
|
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||||
|
fi
|
||||||
|
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||||
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||||
|
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||||
|
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||||
|
ls -lh client/
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ on:
|
|||||||
- 'go.mod'
|
- 'go.mod'
|
||||||
- 'go.sum'
|
- 'go.sum'
|
||||||
- 'sqlc.yaml'
|
- 'sqlc.yaml'
|
||||||
|
- 'Makefile'
|
||||||
- 'internal/**'
|
- 'internal/**'
|
||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- '.golangci.yml'
|
- '.golangci.yml'
|
||||||
@@ -53,6 +54,9 @@ jobs:
|
|||||||
go version
|
go version
|
||||||
golangci-lint --version
|
golangci-lint --version
|
||||||
|
|
||||||
|
- name: Generated code matches queries (sqlc)
|
||||||
|
run: make verify-generate
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
run: 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
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
|
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||||
generate:
|
generate:
|
||||||
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) 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:
|
test:
|
||||||
go test -race ./...
|
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.
|
> 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
|
## Highlights
|
||||||
|
|
||||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Quickstart
|
||||||
|
|
||||||
@@ -24,10 +36,18 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
|
# Your music library. Point ./music at wherever your audio files
|
||||||
|
# live. Mounted read-only — Minstrel never writes to your library.
|
||||||
- ./music:/music:ro
|
- ./music:/music:ro
|
||||||
- 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:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||||
|
# Colon-separated library roots to scan; must match the container
|
||||||
|
# path of the read-only music mount above (/music here).
|
||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -37,6 +57,8 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel
|
POSTGRES_DB: minstrel
|
||||||
|
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||||
|
# The one volume you must never lose; back it up with pg_dump.
|
||||||
volumes: [pgdata:/var/lib/postgresql/data]
|
volumes: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +70,29 @@ volumes:
|
|||||||
docker compose up -d
|
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).
|
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:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
|
|
||||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||||
- `MINSTREL_STORAGE_DATA_DIR` — 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`).
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
|
|
||||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
@@ -66,8 +110,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
|
||||||
|
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||||
|
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||||
|
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||||
|
|
||||||
|
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
|
|
||||||
|
|||||||
@@ -210,4 +210,17 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.ui.test.manifest)
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> { useJUnitPlatform() }
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
// Print the assertion message + full stack trace for failures. The
|
||||||
|
// default console output gives only "AssertionError at Foo.kt:12", and
|
||||||
|
// for a failure inside a `runTest { }` lambda even that line collapses
|
||||||
|
// to the test function's own line (the assertion frames live in the
|
||||||
|
// suspend-lambda class, which Gradle filters out) — leaving nothing to
|
||||||
|
// debug from when the HTML report isn't reachable, as in CI.
|
||||||
|
testLogging {
|
||||||
|
events("failed")
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
showStackTraces = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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.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.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
@@ -19,9 +28,9 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Minstrel"
|
android:theme="@style/Theme.Minstrel"
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
tools:targetApi="34">
|
tools:targetApi="34">
|
||||||
|
|
||||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||||
@@ -48,15 +57,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
android:name="androidx.core.content.FileProvider"
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
android:authorities="${applicationId}.fileprovider"
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
android:exported="false"
|
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||||
android:grantUriPermissions="true">
|
else in the app ever used that authority. -->
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||||
implements Configuration.Provider and supplies the
|
implements Configuration.Provider and supplies the
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import androidx.work.Configuration
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.crossfade
|
||||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.diagnostics.DiagnosticsReporter
|
||||||
|
import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
@@ -27,6 +30,10 @@ import okhttp3.OkHttpClient
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
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
|
@HiltAndroidApp
|
||||||
class MinstrelApplication :
|
class MinstrelApplication :
|
||||||
Application(),
|
Application(),
|
||||||
@@ -155,6 +162,11 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||||
|
|
||||||
|
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||||
|
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||||
|
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||||
|
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||||
|
|
||||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -206,11 +218,18 @@ class MinstrelApplication :
|
|||||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||||
* before Coil makes its first request.
|
* 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 =
|
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||||
}
|
}
|
||||||
|
.crossfade(ART_CROSSFADE_MS)
|
||||||
.build()
|
.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 androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.Invite
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.message != null) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── 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 {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
|
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
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)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SectionCard(
|
||||||
|
icon = Lucide.Music,
|
||||||
|
title = "Tag sources",
|
||||||
|
subtitle = "Metadata enrichment providers",
|
||||||
|
count = counts.tagSources,
|
||||||
|
onClick = { navController.navigate(AdminTagSources) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminRequestsUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -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 androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminUsersUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+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.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
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.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
|||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
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.AlbumDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
|||||||
@GET("api/artists/{id}/tracks")
|
@GET("api/artists/{id}/tracks")
|
||||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||||
|
|
||||||
|
@GET("api/artists/{id}/similar")
|
||||||
|
suspend fun getSimilarArtists(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||||
|
): List<ArtistWire>
|
||||||
|
|
||||||
|
@GET("api/artists/{id}/top-tracks")
|
||||||
|
suspend fun getArtistTopTracks(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||||
|
): List<TrackWire>
|
||||||
|
|
||||||
@GET("api/albums/{id}")
|
@GET("api/albums/{id}")
|
||||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||||
|
|
||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||||
|
const val TOP_TRACKS_LIMIT = 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.auth
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
import com.fabledsword.minstrel.api.endpoints.AuthApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.UserRef
|
import com.fabledsword.minstrel.models.UserRef
|
||||||
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
import com.fabledsword.minstrel.models.wire.LoginRequestBody
|
||||||
@@ -35,6 +36,7 @@ class AuthController @Inject constructor(
|
|||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: AuthApi = retrofit.create()
|
private val api: AuthApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
private val currentUserState = MutableStateFlow<UserRef?>(null)
|
||||||
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
|
||||||
@@ -55,6 +57,10 @@ class AuthController @Inject constructor(
|
|||||||
currentUserState.value = raw?.let { decodeUser(it) }
|
currentUserState.value = raw?.let { decodeUser(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh from /api/me on startup so a remotely-changed account
|
||||||
|
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||||
|
// without a re-login. Best-effort; failures keep the cached value.
|
||||||
|
scope.launch { refreshProfile() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,9 +78,32 @@ class AuthController @Inject constructor(
|
|||||||
)
|
)
|
||||||
currentUserState.value = user
|
currentUserState.value = user
|
||||||
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
|
// Pull the fuller /me shape (carries debug_mode_enabled) right
|
||||||
|
// after login so the diagnostics gate is correct without waiting
|
||||||
|
// for the next startup refresh.
|
||||||
|
scope.launch { refreshProfile() }
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch the caller's profile from /api/me and update currentUser
|
||||||
|
* + persisted userJson. Carries account-level flags (debug mode)
|
||||||
|
* that aren't in the login response. Best-effort: a network failure
|
||||||
|
* leaves the cached identity untouched. No-op when signed out.
|
||||||
|
*/
|
||||||
|
suspend fun refreshProfile() {
|
||||||
|
if (!isSignedIn) return
|
||||||
|
val p = runCatching { meApi.getProfile() }.getOrNull() ?: return
|
||||||
|
val user = UserRef(
|
||||||
|
id = p.id,
|
||||||
|
username = p.username,
|
||||||
|
isAdmin = p.isAdmin,
|
||||||
|
debugModeEnabled = p.debugModeEnabled,
|
||||||
|
)
|
||||||
|
currentUserState.value = user
|
||||||
|
authStore.setUserJson(json.encodeToString(UserRef.serializer(), user))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the local session immediately and best-effort hits
|
* Clears the local session immediately and best-effort hits
|
||||||
* `/api/auth/logout` so the server can drop its session row.
|
* `/api/auth/logout` so the server can drop its session row.
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class AuthStore @Inject constructor(
|
|||||||
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
|
||||||
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
|
||||||
|
|
||||||
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -70,6 +73,7 @@ class AuthStore @Inject constructor(
|
|||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,11 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistCacheSettings(encoded) }
|
scope.launch { persistCacheSettings(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDiagnosticsOptOut(value: Boolean) {
|
||||||
|
diagnosticsOptOutState.value = value
|
||||||
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -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(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
sessionCookie = sessionCookieState.value,
|
||||||
@@ -171,6 +188,7 @@ class AuthStore @Inject constructor(
|
|||||||
CacheSettings.serializer(),
|
CacheSettings.serializer(),
|
||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
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.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
@@ -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.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,8 +63,11 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHomeIndexEntity::class,
|
CachedHomeIndexEntity::class,
|
||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::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,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -81,4 +86,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
|
||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,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.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||||
@@ -105,5 +106,10 @@ object DatabaseModule {
|
|||||||
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
fun provideAudioCacheIndexDao(db: AppDatabase): AudioCacheIndexDao =
|
||||||
db.audioCacheIndexDao()
|
db.audioCacheIndexDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -42,4 +42,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized cache settings. */
|
/** Partial update: change only the serialized cache settings. */
|
||||||
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
|
||||||
suspend fun setCacheSettingsJson(json: String?)
|
suspend fun setCacheSettingsJson(json: String?)
|
||||||
|
|
||||||
|
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||||
|
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||||
|
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
|||||||
)
|
)
|
||||||
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||||
|
|
||||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
suspend fun deleteSections(sections: List<String>)
|
||||||
suspend fun deleteBySection(section: 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")
|
@Query("DELETE FROM cached_home_index")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|||||||
+25
-1
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
|||||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun maxPosition(playlistId: String): Int?
|
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")
|
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun deleteByPlaylist(playlistId: String)
|
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(
|
@Query(
|
||||||
"DELETE FROM cached_playlist_tracks " +
|
"DELETE FROM cached_playlist_tracks " +
|
||||||
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
"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()
|
||||||
|
}
|
||||||
+7
@@ -36,4 +36,11 @@ data class AuthSessionEntity(
|
|||||||
* CacheSettings shape evolves.
|
* CacheSettings shape evolves.
|
||||||
*/
|
*/
|
||||||
val cacheSettingsJson: String? = null,
|
val cacheSettingsJson: String? = null,
|
||||||
|
/**
|
||||||
|
* Per-device opt-out of diagnostics reporting (M9). When the
|
||||||
|
* account's debug mode is enabled by an admin, the user can still
|
||||||
|
* turn reporting OFF on this device (battery/privacy) — that local
|
||||||
|
* choice lives here. Default false = honor the account flag.
|
||||||
|
*/
|
||||||
|
val diagnosticsOptOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
|||||||
* - "rediscover_artists"
|
* - "rediscover_artists"
|
||||||
* - "most_played_tracks"
|
* - "most_played_tracks"
|
||||||
* - "last_played_artists"
|
* - "last_played_artists"
|
||||||
|
* - "you_might_like_albums"
|
||||||
|
* - "you_might_like_artists"
|
||||||
|
*
|
||||||
|
* New `section` values need no schema change — the column stores the
|
||||||
|
* string verbatim and the DAO keys on it.
|
||||||
*
|
*
|
||||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||||
* to the right per-entity endpoint when a tile is rendered.
|
* to the right per-entity endpoint when a tile is rendered.
|
||||||
|
|||||||
Vendored
+28
@@ -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 PLAY_OFFLINE: String = "play_offline"
|
||||||
const val REQUEST_CANCEL: String = "request_cancel"
|
const val REQUEST_CANCEL: String = "request_cancel"
|
||||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||||
|
|
||||||
|
// Durable close-by-id for a play whose server row is already open
|
||||||
|
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||||
|
// a *whole* play when no server row exists. Using close-by-id on
|
||||||
|
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||||
|
// path produced (see 2026-06-11 contract audit).
|
||||||
|
const val PLAY_ENDED: String = "play_ended"
|
||||||
|
|
||||||
|
// #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(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
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 {
|
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||||
@@ -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
|
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||||
@@ -221,6 +279,22 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning; null on payloads queued
|
||||||
|
// before this field existed (decodes to null → server stores NULL).
|
||||||
|
val deviceClass: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
||||||
|
* `POST /api/events` with type=play_ended, closing the already-open server
|
||||||
|
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
||||||
|
* idempotent (re-ending updates the same row and skips a duplicate
|
||||||
|
* skip_events insert), so a lost-response retry can't duplicate history.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayEndedPayload(
|
||||||
|
val playEventId: String,
|
||||||
|
val durationPlayedMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+216
-157
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
||||||
|
|
||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||||
@@ -10,7 +12,11 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
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 com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,25 +44,24 @@ import javax.inject.Singleton
|
|||||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||||
* 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:
|
* Triggered by:
|
||||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||||
* init block to subscribe)
|
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
* start with a persisted cookie)
|
||||||
* value (sign-in, app launch with 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
|
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
* typical use, and permanent failures are now dropped rather than retried
|
||||||
* sign-in + app-open triggers cover the common offline → online
|
* forever.
|
||||||
* transition (operator unlocks phone, opens app).
|
|
||||||
*
|
|
||||||
* Failed rows stay in the queue with their `attempts` and
|
|
||||||
* `lastAttemptAt` updated. They get another chance on the next
|
|
||||||
* trigger; no exponential backoff or max-attempts yet — the rows are
|
|
||||||
* small (low single-digit count for typical use) so retry-forever is
|
|
||||||
* cheap.
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MutationReplayer @Inject constructor(
|
class MutationReplayer @Inject constructor(
|
||||||
@@ -60,6 +69,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val likesApi: LikesApi = retrofit.create()
|
private val likesApi: LikesApi = retrofit.create()
|
||||||
@@ -72,6 +82,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private enum class Outcome { SENT, DROP, RETRY }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -79,6 +91,16 @@ class MutationReplayer @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { drainSafe() }
|
.collect { drainSafe() }
|
||||||
}
|
}
|
||||||
|
// Server-reachable-again: replay queued writes the moment the
|
||||||
|
// health signal recovers, so an offline→online transition mid-
|
||||||
|
// session doesn't wait for the next cold start / re-auth.
|
||||||
|
scope.launch {
|
||||||
|
networkStatus.state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it }
|
||||||
|
.collect { drainSafe() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||||
@@ -93,193 +115,230 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
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) {
|
for (row in rows) {
|
||||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
if (row.id in superseded) {
|
||||||
if (sent) {
|
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
} else {
|
continue
|
||||||
dao.recordAttempt(row.id, Clock.System.now())
|
}
|
||||||
|
when (outcomeFor(row)) {
|
||||||
|
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
||||||
|
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||||
* failure (transport, server error, decode). The replayer treats
|
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||||
* false as "leave the row for next pass" — it doesn't try to
|
* by [outcomeFor] — dispatchers no longer swallow.
|
||||||
* distinguish permanent rejections from transient ones; a row
|
|
||||||
* that consistently fails will accumulate attempt count and
|
|
||||||
* lastAttemptAt for diagnostics.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||||
|
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
else -> {
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
// Unknown kind — drop the row by claiming success so a
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
// stale schema entry can't wedge the queue forever.
|
else -> Outcome.DROP
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||||
val kindPath = when (decoded.entityType) {
|
val kindPath = when (decoded.entityType) {
|
||||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||||
else -> return true // unknown variant — drop.
|
else -> return Outcome.DROP
|
||||||
}
|
}
|
||||||
return try {
|
if (decoded.desiredState) {
|
||||||
if (decoded.desiredState) {
|
likesApi.like(kindPath, decoded.entityId)
|
||||||
likesApi.like(kindPath, decoded.entityId)
|
} else {
|
||||||
} else {
|
likesApi.unlike(kindPath, decoded.entityId)
|
||||||
likesApi.unlike(kindPath, decoded.entityId)
|
|
||||||
}
|
|
||||||
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 decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||||
val body = CreateRequestBody(
|
discoverApi.createRequest(
|
||||||
kind = decoded.kind,
|
CreateRequestBody(
|
||||||
artistMbid = decoded.artistMbid,
|
kind = decoded.kind,
|
||||||
artistName = decoded.artistName,
|
artistMbid = decoded.artistMbid,
|
||||||
albumMbid = decoded.albumMbid,
|
artistName = decoded.artistName,
|
||||||
albumTitle = decoded.albumTitle,
|
albumMbid = decoded.albumMbid,
|
||||||
trackMbid = decoded.trackMbid,
|
albumTitle = decoded.albumTitle,
|
||||||
trackTitle = decoded.trackTitle,
|
trackMbid = decoded.trackMbid,
|
||||||
|
trackTitle = decoded.trackTitle,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return try {
|
return Outcome.SENT
|
||||||
discoverApi.createRequest(body)
|
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: the row stays in the queue with
|
|
||||||
// attempts + lastAttemptAt incremented; next drain pass
|
|
||||||
// retries. Per-attempt diagnostic logging lands when we
|
|
||||||
// wire up Timber at the replayer level.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||||
return try {
|
quarantineApi.unflag(decoded.trackId)
|
||||||
quarantineApi.unflag(decoded.trackId)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: the row stays in the queue with
|
|
||||||
// attempts + lastAttemptAt incremented; next drain pass
|
|
||||||
// retries. Per-attempt diagnostic logging lands when we
|
|
||||||
// wire up Timber at the replayer level.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||||
return try {
|
playlistsApi.appendTracks(
|
||||||
playlistsApi.appendTracks(
|
playlistId = decoded.playlistId,
|
||||||
playlistId = decoded.playlistId,
|
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||||
return try {
|
quarantineApi.flag(
|
||||||
quarantineApi.flag(
|
FlagRequest(
|
||||||
FlagRequest(
|
trackId = decoded.trackId,
|
||||||
trackId = decoded.trackId,
|
reason = decoded.reason,
|
||||||
reason = decoded.reason,
|
notes = decoded.notes,
|
||||||
notes = decoded.notes,
|
),
|
||||||
),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||||
return try {
|
eventsApi.playOffline(
|
||||||
eventsApi.playOffline(
|
PlayOfflineRequest(
|
||||||
PlayOfflineRequest(
|
trackId = decoded.trackId,
|
||||||
trackId = decoded.trackId,
|
clientId = decoded.clientId,
|
||||||
clientId = decoded.clientId,
|
at = decoded.atIso,
|
||||||
at = decoded.atIso,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
source = decoded.source,
|
||||||
source = decoded.source,
|
deviceClass = decoded.deviceClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
true
|
return Outcome.SENT
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||||
return try {
|
requestsApi.cancel(decoded.requestId)
|
||||||
requestsApi.cancel(decoded.requestId)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
/**
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||||
return try {
|
*
|
||||||
playbackErrorsApi.report(
|
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||||
PlaybackErrorReportRequest(
|
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||||
trackId = decoded.trackId,
|
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||||
kind = decoded.kind,
|
* already holds, so there is nothing left to send.
|
||||||
detail = decoded.detail,
|
*/
|
||||||
clientId = decoded.clientId,
|
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||||
),
|
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||||
)
|
if (decoded.desiredSnoozed) {
|
||||||
true
|
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||||
} catch (
|
} else {
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
|
playbackErrorsApi.report(
|
||||||
|
PlaybackErrorReportRequest(
|
||||||
|
trackId = decoded.trackId,
|
||||||
|
kind = decoded.kind,
|
||||||
|
detail = decoded.detail,
|
||||||
|
clientId = decoded.clientId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+43
-2
@@ -1,7 +1,10 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
@@ -15,9 +18,15 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
|||||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -50,10 +59,26 @@ class SyncController @Inject constructor(
|
|||||||
private val trackDao: CachedTrackDao,
|
private val trackDao: CachedTrackDao,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: SyncApi = retrofit.create()
|
private val api: SyncApi = retrofit.create()
|
||||||
|
|
||||||
|
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
||||||
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human copy for the most recent sync failure; null after any clean
|
||||||
|
* pass. Lets the Library screen distinguish "empty because the first
|
||||||
|
* sync failed" (show error + retry) from "genuinely empty library"
|
||||||
|
* (show welcome copy) — a failed sync over a populated cache stays
|
||||||
|
* silent, since the cached content is still the better surface.
|
||||||
|
*/
|
||||||
|
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -61,11 +86,27 @@ class SyncController @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { syncSafe() }
|
.collect { syncSafe() }
|
||||||
}
|
}
|
||||||
|
// Re-sync when the server becomes reachable again mid-session, so a
|
||||||
|
// delta missed while offline lands without waiting for a cold start.
|
||||||
|
scope.launch {
|
||||||
|
networkStatus.state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it }
|
||||||
|
.collect { syncSafe() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
runCatching { sync() }
|
if (!mutex.tryLock()) return
|
||||||
|
try {
|
||||||
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sync() {
|
private suspend fun sync() {
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
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.BuildConfig
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
|||||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||||
* source, and the playback-error reporter.
|
* source, and the playback-error reporter.
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
* - 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.
|
* Version compatibility is a byproduct of the same /healthz response.
|
||||||
*
|
*
|
||||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
connectivity: ConnectivityObserver,
|
connectivity: ConnectivityObserver,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) : DefaultLifecycleObserver {
|
||||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||||
private val machine = ReachabilityMachine()
|
private val machine = ReachabilityMachine()
|
||||||
private val lastProbeAtMs = AtomicLong(0)
|
private val lastProbeAtMs = AtomicLong(0)
|
||||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
scope.launch { reduceLoop() }
|
scope.launch { reduceLoop() }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
connectivity.online.collect { up ->
|
connectivity.online.collect { up ->
|
||||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
|||||||
scope.launch { probeOnce(force = true) }
|
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() {
|
private suspend fun reduceLoop() {
|
||||||
for (intent in intents) {
|
for (intent in intents) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
+27
-1
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
|||||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
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 —
|
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
|||||||
recentOpFailures.clear()
|
recentOpFailures.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
/**
|
||||||
|
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||||
|
*
|
||||||
|
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||||
|
* recorded one are dropped rather than stacked: see that constant for why
|
||||||
|
* a burst must not corroborate itself.
|
||||||
|
*/
|
||||||
fun onOpFailure(nowMs: Long) {
|
fun onOpFailure(nowMs: Long) {
|
||||||
pruneOpFailures(nowMs)
|
pruneOpFailures(nowMs)
|
||||||
|
val last = recentOpFailures.lastOrNull()
|
||||||
|
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||||
recentOpFailures.addLast(nowMs)
|
recentOpFailures.addLast(nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
||||||
|
* after the collector subscribed — the StateFlow's replayed current value
|
||||||
|
* is dropped, so only genuine down→up transitions fire (a screen that
|
||||||
|
* subscribes while already Healthy doesn't double-load).
|
||||||
|
*
|
||||||
|
* This is the screen-level half of the recovery idiom the app-lifetime
|
||||||
|
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
||||||
|
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
||||||
|
* its load, so a surface that failed while the server was unreachable
|
||||||
|
* heals itself the moment connectivity returns instead of sitting in the
|
||||||
|
* failed state until a manual pull-to-refresh.
|
||||||
|
*/
|
||||||
|
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.filter { it }
|
||||||
|
.map { }
|
||||||
+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.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
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.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
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.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
|||||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||||
api.listSuggestions().map { it.toDomain() }
|
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> =
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
|||||||
RequestOutcome.QUEUED
|
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) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
|||||||
name = name,
|
name = name,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
attribution = attribution.map { it.toDomain() },
|
attribution = attribution.map { it.toDomain() },
|
||||||
|
matchedTags = matchedTags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
|||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
snoozedUntil = snoozedUntil,
|
||||||
|
)
|
||||||
|
|
||||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = artistMbid,
|
artistMbid = artistMbid,
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -103,12 +105,25 @@ private fun DiscoverBody(
|
|||||||
ResultsState.Idle -> SuggestionsPane(
|
ResultsState.Idle -> SuggestionsPane(
|
||||||
state = state.suggestions,
|
state = state.suggestions,
|
||||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
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 ->
|
onRequest = { s ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val outcome = viewModel.requestSuggestion(s)
|
val outcome = viewModel.requestSuggestion(s)
|
||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
is ResultsState.Loaded -> ResultsList(
|
||||||
@@ -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
|
@Composable
|
||||||
private fun SuggestionsPane(
|
private fun SuggestionsPane(
|
||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
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(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
|
snoozeUi = snoozeUi,
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,6 +229,7 @@ private fun SuggestionsPane(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsList(
|
private fun SuggestionsList(
|
||||||
items: List<ArtistSuggestionRef>,
|
items: List<ArtistSuggestionRef>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -199,13 +238,61 @@ private fun SuggestionsList(
|
|||||||
) {
|
) {
|
||||||
item { SuggestionsHeader() }
|
item { SuggestionsHeader() }
|
||||||
if (items.isEmpty()) {
|
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 {
|
} else {
|
||||||
items(items = items, key = { it.mbid }) { s ->
|
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()
|
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.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
internal fun SuggestionTile(
|
||||||
|
s: ArtistSuggestionRef,
|
||||||
|
snoozed: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
onSnooze: () -> Unit,
|
||||||
|
onUnsnooze: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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(
|
||||||
text = s.attributionText,
|
text = secondary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(onClick = onRequest) { Text("Request") }
|
if (snoozed) {
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onRequest) { Text("Request") }
|
||||||
|
IconButton(onClick = onSnooze) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Clock,
|
||||||
|
// Rule #101: the label states what happens, and passes no
|
||||||
|
// judgement on the music. Never "not for me".
|
||||||
|
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the parked list. This exists because a snoozed candidate is by
|
||||||
|
* definition absent from the deck above, so without it there is no route back
|
||||||
|
* to an un-snooze once the card has gone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Back ${row.returnsIn()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -25,6 +28,17 @@ data class DiscoverState(
|
|||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
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 {
|
sealed interface SuggestionState {
|
||||||
@@ -46,6 +60,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +68,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||||
|
// failed while the server was unreachable once health returns.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
val s = internal.value
|
||||||
|
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
||||||
|
if (s.results is ResultsState.Error) runSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -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() {
|
fun runSearch() {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,11 +36,15 @@ import javax.inject.Singleton
|
|||||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||||
* per-item tile providers.
|
* per-item tile providers.
|
||||||
*
|
*
|
||||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
* one transaction (so the rows update together in a single emission
|
||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* rather than collapsing and refilling), and pre-warms the top artists
|
||||||
|
* via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
|
// Per-section observe accessors (one per Home row) inflate the function
|
||||||
|
// count past detekt's 11 default — accessor density, not complexity.
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
class HomeRepository @Inject constructor(
|
class HomeRepository @Inject constructor(
|
||||||
private val homeIndexDao: CachedHomeIndexDao,
|
private val homeIndexDao: CachedHomeIndexDao,
|
||||||
private val metadataProvider: MetadataProvider,
|
private val metadataProvider: MetadataProvider,
|
||||||
@@ -76,68 +82,104 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||||
|
|
||||||
|
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
||||||
|
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
||||||
|
|
||||||
|
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
|
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||||
|
|
||||||
|
/** 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,
|
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* a single transaction, then pre-warms the top artists. Missing
|
||||||
* index change; missing entity rows hydrate via the on-miss path.
|
* 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() {
|
suspend fun refreshIndex() {
|
||||||
val wire = api.getHomeIndex()
|
val wire = api.getHomeIndex()
|
||||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
homeIndexDao.replaceSections(
|
||||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
sections = ALL_SECTIONS,
|
||||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||||
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
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) +
|
||||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
|
||||||
homeIndexDao.deleteBySection(section)
|
)
|
||||||
if (ids.isEmpty()) return
|
prewarmer.warm(
|
||||||
homeIndexDao.upsertAll(
|
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||||
ids.mapIndexed { index, id ->
|
|
||||||
CachedHomeIndexEntity(
|
|
||||||
section = section,
|
|
||||||
position = index,
|
|
||||||
entityType = entityType,
|
|
||||||
entityId = id,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun rowsFor(
|
||||||
|
section: String,
|
||||||
|
entityType: String,
|
||||||
|
ids: List<String>,
|
||||||
|
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||||
|
CachedHomeIndexEntity(
|
||||||
|
section = section,
|
||||||
|
position = index,
|
||||||
|
entityType = entityType,
|
||||||
|
entityId = id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The section's ordered entity ids, deduplicated.
|
||||||
|
*
|
||||||
|
* Room re-runs the query on every write to `cached_home_index` — and
|
||||||
|
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||||
|
* comparing whole rows would call every rewrite a change. Comparing
|
||||||
|
* the id list instead means a section whose contents didn't actually
|
||||||
|
* move never restarts the `flatMapLatest` below, which would
|
||||||
|
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||||
|
* and flicker unchanged tiles (issue #2327).
|
||||||
|
*/
|
||||||
|
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||||
|
homeIndexDao.observeBySection(section)
|
||||||
|
.map { rows -> rows.map { it.entityId } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
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_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||||
|
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||||
|
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||||
|
|
||||||
|
/** 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
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -55,6 +65,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -75,27 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
|||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
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.asCacheFirstStateFlow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
@@ -107,6 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
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 ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -116,6 +154,8 @@ data class HomeSections(
|
|||||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
|
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||||
|
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isAllEmpty: Boolean
|
val isAllEmpty: Boolean
|
||||||
get() = playlists.isEmpty() &&
|
get() = playlists.isEmpty() &&
|
||||||
@@ -123,7 +163,9 @@ data class HomeSections(
|
|||||||
rediscoverAlbums.isEmpty() &&
|
rediscoverAlbums.isEmpty() &&
|
||||||
rediscoverArtists.isEmpty() &&
|
rediscoverArtists.isEmpty() &&
|
||||||
mostPlayedTracks.isEmpty() &&
|
mostPlayedTracks.isEmpty() &&
|
||||||
lastPlayedArtists.isEmpty()
|
lastPlayedArtists.isEmpty() &&
|
||||||
|
youMightLikeAlbums.isEmpty() &&
|
||||||
|
youMightLikeArtists.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
@@ -135,6 +177,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
|
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -156,14 +199,29 @@ class HomeViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
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
|
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||||
@@ -176,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||||
}.shuffled()
|
}.shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||||
}
|
}
|
||||||
@@ -213,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||||
if (detail.tracks.isEmpty()) {
|
if (detail.tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This album has no tracks to play.")
|
snackbarMessages.trySend("This album has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -238,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This artist has no tracks to play.")
|
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -263,39 +321,81 @@ class HomeViewModel @Inject constructor(
|
|||||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||||
poolMessages.trySend(it)
|
snackbarMessages.trySend(it)
|
||||||
}
|
}
|
||||||
}.join()
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls both /home/index and the playlists list. Returns the Job
|
* Pulls /home/index, the playlists list and the system-playlist
|
||||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
* status. Returns true when the load-bearing /home/index pull
|
||||||
* actual completion before hiding the indicator.
|
* 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 {
|
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
// /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 lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
.onSuccess { systemStatusInternal.value = it }
|
.onSuccess { systemStatusInternal.value = it }
|
||||||
}
|
}
|
||||||
home.join()
|
|
||||||
lists.join()
|
lists.join()
|
||||||
status.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>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Five home-section flows merged into the sections struct without
|
* The core five section flows merged into the sections struct. Split
|
||||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
* out so each combine call stays inside the coroutines built-in arity
|
||||||
* Splitting it like this keeps each combine call inside the
|
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
* across heterogeneous types would need. [observeHomeSections] then
|
||||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||||
|
* playlists flow.
|
||||||
*/
|
*/
|
||||||
private fun observeHomeSections() = combine(
|
private fun observeCoreSections() = combine(
|
||||||
homeRepository.observeRecentlyAddedAlbums(),
|
homeRepository.observeRecentlyAddedAlbums(),
|
||||||
homeRepository.observeRediscoverAlbums(),
|
homeRepository.observeRediscoverAlbums(),
|
||||||
homeRepository.observeRediscoverArtists(),
|
homeRepository.observeRediscoverArtists(),
|
||||||
@@ -311,11 +411,145 @@ class HomeViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeHomeSections() = combine(
|
||||||
|
observeCoreSections(),
|
||||||
|
homeRepository.observeYouMightLikeAlbums(),
|
||||||
|
homeRepository.observeYouMightLikeArtists(),
|
||||||
|
) { core, ymlAlbums, ymlArtists ->
|
||||||
|
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
||||||
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
when {
|
||||||
|
!merged.isAllEmpty -> UiState.Success(merged)
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Updating veil ───────────────────────────────────────────────
|
||||||
|
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||||
|
// property initialisers and init blocks in declaration order.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the veil watches to decide Home has stopped moving: the whole
|
||||||
|
* rendered state, plus how many covers are still loading.
|
||||||
|
*
|
||||||
|
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||||
|
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||||
|
* album — changes this value and re-arms the veil's quiet window.
|
||||||
|
*
|
||||||
|
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||||
|
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||||
|
* ([MetadataProvider] swallows those errors), so treating "no
|
||||||
|
* skeletons left" as the settle condition would pin the veil to its
|
||||||
|
* hard ceiling on every refresh. They're covered by the content key
|
||||||
|
* instead: each tile that lands re-arms the window, and once they stop
|
||||||
|
* landing the screen is genuinely still.
|
||||||
|
*/
|
||||||
|
private val settleSignal: Flow<VeilSettleState> =
|
||||||
|
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||||
|
VeilSettleState(
|
||||||
|
contentKey = state,
|
||||||
|
hasContent = state is UiState.Success,
|
||||||
|
quiescent = artInFlight == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val veil = UpdateVeilController(
|
||||||
|
scope = viewModelScope,
|
||||||
|
settleSignal = settleSignal,
|
||||||
|
shouldVeil = {
|
||||||
|
// Only worth hiding churn when there's already content to
|
||||||
|
// hide. A cold load over an empty cache keeps its skeleton —
|
||||||
|
// veiling that would replace a useful affordance with an
|
||||||
|
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||||
|
// still reads Loading until the screen subscribes, so on a
|
||||||
|
// process restore over a warm cache it would say "no content"
|
||||||
|
// right before the cache emits.
|
||||||
|
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||||
|
},
|
||||||
|
onSessionEnd = ::reportRefreshOutcome,
|
||||||
|
work = ::runRefresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the user how a refresh *they asked for* went, in the one case
|
||||||
|
* the veil can't: when nothing changed there's no veil to see, and a
|
||||||
|
* pull that produces no visible response at all reads as broken.
|
||||||
|
*
|
||||||
|
* Only user-initiated sessions say anything. The same outcome from a
|
||||||
|
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||||
|
* is noise, and "Already up to date" on every launch would be worse
|
||||||
|
* than silence (operator's call, 2026-07-31).
|
||||||
|
*/
|
||||||
|
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||||
|
if (!result.userInitiated) return
|
||||||
|
when (result.outcome) {
|
||||||
|
// The veil was the feedback.
|
||||||
|
VeilOutcome.CHANGED -> return
|
||||||
|
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||||
|
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the "Updating your mixes…" veil should be raised.
|
||||||
|
*
|
||||||
|
* Raised only when a refresh actually changes what's on screen, and then
|
||||||
|
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||||
|
* for a fixed delay after the network pull returns (issue #2327). A
|
||||||
|
* refresh that returns what's already cached shows no veil at all;
|
||||||
|
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Every refresh path goes through the controller, which decides
|
||||||
|
// per session whether to raise the veil. That includes the initial
|
||||||
|
// load: over a warm cache it's a full re-pull that churns every
|
||||||
|
// section, and it used to run completely unveiled.
|
||||||
|
veil.request()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// This is also the recovery that keeps trying after the veil has
|
||||||
|
// given up and lowered; the controller sets no latch against it.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { veil.request() }
|
||||||
|
}
|
||||||
|
// Server-side changes that rewrite what Home renders (#968 and
|
||||||
|
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||||
|
// web SSE consumer.
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||||
|
.collect { veil.request() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||||
|
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||||
|
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||||
|
* Recently added (and Home never reacted to it at all before).
|
||||||
|
*/
|
||||||
|
private val VEILED_EVENT_KINDS = setOf(
|
||||||
|
"playlist.system_rebuilt",
|
||||||
|
"playlist.created",
|
||||||
|
"playlist.updated",
|
||||||
|
"playlist.deleted",
|
||||||
|
"playlist.tracks_changed",
|
||||||
|
"scan.run_finished",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
@@ -343,46 +577,114 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refreshFromPull() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
// Key Crossfade on the state CLASS, not the instance. Each
|
Box(Modifier.fillMaxSize()) {
|
||||||
// section emission produces a new UiState.Success(data); if
|
// Every cover below reports its load state to the tracker,
|
||||||
// we keyed on `state` directly, every per-section
|
// so the veil can wait for artwork instead of guessing.
|
||||||
// hydration tick would re-run the 300ms crossfade, and
|
CompositionLocalProvider(
|
||||||
// first-sign-in (six sections cascading in) reads as
|
LocalArtSettleTracker provides viewModel.artTracker,
|
||||||
// continuous flicker. Keying on the class restricts the
|
) {
|
||||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
// lets normal Success→Success recompositions update the
|
|
||||||
// LazyColumn without a fade.
|
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
|
||||||
when (val s = state) {
|
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
|
||||||
UiState.Empty -> EmptyState(
|
|
||||||
title = "Welcome to Minstrel",
|
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
|
||||||
"settings, then come back here for system playlists " +
|
|
||||||
"and recommendations.",
|
|
||||||
)
|
|
||||||
is UiState.Error -> EmptyState(
|
|
||||||
title = "Couldn't load home",
|
|
||||||
body = s.message,
|
|
||||||
)
|
|
||||||
is UiState.Success -> HomeSuccessContent(
|
|
||||||
sections = s.data,
|
|
||||||
systemStatus = systemStatus,
|
|
||||||
offline = offline,
|
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
|
||||||
onPlayPool = viewModel::playPool,
|
|
||||||
onPlayAlbum = viewModel::playAlbum,
|
|
||||||
onPlayArtist = viewModel::playArtistShuffled,
|
|
||||||
onPlayPlaylist = viewModel::playPlaylist,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||||
|
// hide behind an "Updating your mixes…" wipe that stays up
|
||||||
|
// until the screen has actually stopped moving.
|
||||||
|
UpdatingVeil(visible = updating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||||
|
*
|
||||||
|
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||||
|
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||||
|
* directly, every per-section hydration tick would re-run the 300ms
|
||||||
|
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||||
|
* continuous flicker. Keying on the class restricts the animation to
|
||||||
|
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||||
|
* recompositions update the LazyColumn without a fade.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HomeStateCrossfade(
|
||||||
|
state: UiState<HomeSections>,
|
||||||
|
systemStatus: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
|
when (val s = state) {
|
||||||
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
|
UiState.Empty -> EmptyState(
|
||||||
|
title = "Welcome to Minstrel",
|
||||||
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
|
"settings, then come back here for system playlists " +
|
||||||
|
"and recommendations.",
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load home",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.retry() },
|
||||||
|
)
|
||||||
|
is UiState.Success -> HomeSuccessContent(
|
||||||
|
sections = s.data,
|
||||||
|
systemStatus = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
|
onMostPlayedTap = viewModel::playMostPlayed,
|
||||||
|
onPlayPool = viewModel::playPool,
|
||||||
|
onPlayAlbum = viewModel::playAlbum,
|
||||||
|
onPlayArtist = viewModel::playArtistShuffled,
|
||||||
|
onPlayPlaylist = viewModel::playPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
||||||
|
* holds while an automatic refresh repopulates Home, then wipes off.
|
||||||
|
* Near-opaque so the section churn underneath never shows; swallows taps
|
||||||
|
* while raised so a mid-hydration tile can't be hit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
||||||
|
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
||||||
|
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
||||||
|
Text(
|
||||||
|
text = "Updating your mixes…",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,10 +754,12 @@ private fun HomeSuccessContent(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover /
|
// placeholder cards filling the For You / Discover slots
|
||||||
// 3× Songs-like slots that haven't generated yet. When
|
// that haven't generated yet. When offline, the cache-backed
|
||||||
// offline, the cache-backed pool cards lead the row.
|
// pool cards lead the row. Songs-like now lives in its own
|
||||||
|
// dedicated row below (#1491), no longer inside this carousel.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
|
title = "Playlists",
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -463,6 +767,26 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||||
|
// of the Playlists carousel into its own row so it shows a wider
|
||||||
|
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||||
|
// nothing to show (offline with none cached).
|
||||||
|
songsLikeSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
status = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
youMightLikeSection(
|
||||||
|
albums = sections.youMightLikeAlbums,
|
||||||
|
artists = sections.youMightLikeArtists,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onPlayAlbum = onPlayAlbum,
|
||||||
|
onPlayArtist = onPlayArtist,
|
||||||
|
)
|
||||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||||
rediscoverSection(
|
rediscoverSection(
|
||||||
albums = sections.rediscoverAlbums,
|
albums = sections.rediscoverAlbums,
|
||||||
@@ -478,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(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -667,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
|
@Composable
|
||||||
private fun AlbumsRow(
|
private fun AlbumsRow(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -692,13 +1100,14 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
|
title: String,
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = title) {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -749,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row.
|
||||||
*
|
*
|
||||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
* Online: For You + Discover fixed slots (real card when generated,
|
||||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* user-owned playlists.
|
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||||
|
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||||
*
|
*
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
* same real playlists in curated order but stably partitioned fully-cached
|
||||||
@@ -780,7 +1190,7 @@ internal fun buildPlaylistsRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||||
private fun buildOnlineRow(
|
private fun buildOnlineRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
@@ -792,12 +1202,7 @@ private fun buildOnlineRow(
|
|||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
|
||||||
out += songsLike.getOrNull(i)
|
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
@@ -805,16 +1210,44 @@ private fun buildOnlineRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||||
|
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||||
|
*
|
||||||
|
* Online: every generated mix as a real card; when none exist yet, a few
|
||||||
|
* placeholders so the building / seed-needed state stays visible.
|
||||||
|
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||||
|
* an empty list — hiding the whole section — when nothing is cached.
|
||||||
|
*/
|
||||||
|
internal fun buildSongsLikeRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||||
|
return when {
|
||||||
|
offline -> {
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||||
|
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
* 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> {
|
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||||
val out = mutableListOf<PlaylistRef>()
|
val out = mutableListOf<PlaylistRef>()
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||||
}
|
}
|
||||||
@@ -829,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
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
|
* 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> =
|
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||||
api.getArtistTracks(id).map { it.toDomain() }
|
api.getArtistTracks(id).map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||||
|
* strip. Network-only — non-critical UI, callers swallow failures
|
||||||
|
* and render nothing rather than blocking the screen.
|
||||||
|
*/
|
||||||
|
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||||
|
api.getSimilarArtists(id).map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||||
|
* panel. Network-only; tapping a row plays this exact list as a
|
||||||
|
* queue, so no persistence is needed.
|
||||||
|
*/
|
||||||
|
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||||
|
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, so coming back to the screen shows content, not an error.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AlbumDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+120
-16
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||||
|
|
||||||
package com.fabledsword.minstrel.library.ui
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items as lazyItems
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Play
|
import com.composables.icons.lucide.Play
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.models.albumCoverPath
|
import com.fabledsword.minstrel.models.albumCoverPath
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.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.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||||
|
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -98,27 +107,40 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success -> {
|
is ArtistDetailUiState.Success ->
|
||||||
val artistLiked by viewModel.artistLiked
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
ArtistBody(
|
|
||||||
detail = s.detail,
|
|
||||||
artistLiked = artistLiked,
|
|
||||||
onPlay = viewModel::playArtist,
|
|
||||||
onToggleLike = viewModel::toggleLikeArtist,
|
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
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) {
|
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||||
@@ -127,12 +149,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistBody(
|
private fun ArtistBody(
|
||||||
detail: ArtistDetailRef,
|
state: ArtistDetailUiState.Success,
|
||||||
artistLiked: Boolean,
|
artistLiked: Boolean,
|
||||||
|
playingTrackId: String?,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onTopTrackClick: (Int) -> Unit,
|
||||||
|
onSimilarArtistClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val detail = state.detail
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -149,6 +175,23 @@ private fun ArtistBody(
|
|||||||
onToggleLike = onToggleLike,
|
onToggleLike = onToggleLike,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (state.topTracks.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
TopTracksPanel(
|
||||||
|
tracks = state.topTracks,
|
||||||
|
playingTrackId = playingTrackId,
|
||||||
|
onTrackClick = onTopTrackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.similarArtists.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
SimilarArtistsStrip(
|
||||||
|
artists = state.similarArtists,
|
||||||
|
onArtistClick = onSimilarArtistClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (detail.albums.isEmpty()) {
|
if (detail.albums.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyAlbumsHint()
|
EmptyAlbumsHint()
|
||||||
@@ -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
|
@Composable
|
||||||
private fun ArtistHeader(
|
private fun ArtistHeader(
|
||||||
artist: ArtistRef,
|
artist: ArtistRef,
|
||||||
|
|||||||
+65
-1
@@ -5,10 +5,13 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -23,13 +26,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface ArtistDetailUiState {
|
sealed interface ArtistDetailUiState {
|
||||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||||
data class Success(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
|
data class Error(val message: String) : ArtistDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -63,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||||
|
// whole load; from Success, re-pull just the secondary sections —
|
||||||
|
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||||
|
// during the first load leaves them permanently absent otherwise.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
when (internal.value) {
|
||||||
|
is ArtistDetailUiState.Error -> refresh()
|
||||||
|
is ArtistDetailUiState.Success -> loadSecondarySections()
|
||||||
|
is ArtistDetailUiState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleLikeArtist() {
|
fun toggleLikeArtist() {
|
||||||
@@ -82,6 +104,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
|
loadSecondarySections()
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
@@ -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
|
* Pull every track across the artist's albums, shuffle, and start
|
||||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||||
@@ -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(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
UiState.Success(LibraryData(artists, albums))
|
||||||
|
// Empty cache + failed sync is a load failure, not an empty
|
||||||
|
// library — surface it so the user gets a Retry instead of
|
||||||
|
// the welcome copy. Heals automatically: SyncController
|
||||||
|
// re-syncs on health recovery, clearing lastSyncError.
|
||||||
|
syncError != null -> UiState.Error(syncError)
|
||||||
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
|||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
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)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
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
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String = "",
|
val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionRef> = emptyList(),
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||||
|
val matchedTags: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val attributionText: String
|
val attributionText: String
|
||||||
get() {
|
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 {
|
companion object {
|
||||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion the user parked with "not right now" (#2374).
|
||||||
|
*
|
||||||
|
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||||
|
* its own, and never reaches the taste profile. Anything that treats this as
|
||||||
|
* negative preference signal is a bug.
|
||||||
|
*
|
||||||
|
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||||
|
* decides whether a snooze is still in effect — every row the client receives
|
||||||
|
* already is — so this is read purely to phrase "back in about 3 months".
|
||||||
|
*/
|
||||||
|
data class SuggestionSnoozeRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val snoozedUntil: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Relative return phrase for the manage list. Relative rather than a
|
||||||
|
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||||
|
* user never asked for.
|
||||||
|
*
|
||||||
|
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||||
|
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||||
|
* is on screen, so the server still considers it snoozed, and guessing is
|
||||||
|
* better than rendering an empty line.
|
||||||
|
*/
|
||||||
|
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
|
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||||
|
.getOrNull()?.minus(nowMs)
|
||||||
|
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||||
|
// already lapsed by our clock though the server still returned the row
|
||||||
|
// (the two disagree). Neither is "today", which would read as a real
|
||||||
|
// prediction.
|
||||||
|
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||||
|
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||||
|
return when {
|
||||||
|
days < 1 -> "today"
|
||||||
|
days == 1 -> "tomorrow"
|
||||||
|
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||||
|
else -> {
|
||||||
|
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||||
|
if (months == 1) "in about a month" else "in about $months months"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 86_400_000.0
|
||||||
|
|
||||||
|
// Below this, days read more naturally than a rounded month count.
|
||||||
|
//
|
||||||
|
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||||
|
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||||
|
// threshold above 30 sends all of those down the days branch instead.
|
||||||
|
// This was 45 and the singular branch was dead code — the unit test
|
||||||
|
// for it is what surfaced that.
|
||||||
|
const val DAYS_BEFORE_MONTHS = 30
|
||||||
|
const val DAYS_PER_MONTH = 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,4 +16,11 @@ data class UserRef(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Account-level diagnostics opt-in (M9). NOT present in the login
|
||||||
|
* response (that's the narrow UserView) — populated by the /api/me
|
||||||
|
* refresh in AuthController so a remote admin flip reaches the
|
||||||
|
* device. Default false until the first refresh.
|
||||||
|
*/
|
||||||
|
val debugModeEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = "",
|
val name: String = "",
|
||||||
@SerialName("image_url") val imageUrl: String = "",
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionWire> = emptyList(),
|
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,
|
@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`
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
* payload shape. Optional fields are emitted only when non-null
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
@file:OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
||||||
package com.fabledsword.minstrel.models.wire
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.EncodeDefault
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -15,10 +19,16 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayStartedRequest(
|
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("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -28,24 +38,26 @@ data class PlayStartedResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayEndedRequest(
|
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("play_event_id") val playEventId: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlaySkippedRequest(
|
data class PlaySkippedRequest(
|
||||||
val type: String = "play_skipped",
|
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
||||||
@SerialName("play_event_id") val playEventId: String,
|
@SerialName("play_event_id") val playEventId: String,
|
||||||
@SerialName("position_ms") val positionMs: Long,
|
@SerialName("position_ms") val positionMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayOfflineRequest(
|
data class PlayOfflineRequest(
|
||||||
val type: String = "play_offline",
|
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
|
|||||||
* `internal/api/types.go HomeIndexPayload`).
|
* `internal/api/types.go HomeIndexPayload`).
|
||||||
*
|
*
|
||||||
* Section name implies entity type; no per-entry type tag is needed:
|
* Section name implies entity type; no per-entry type tag is needed:
|
||||||
* - recentlyAddedAlbums → album
|
* - recentlyAddedAlbums → album
|
||||||
* - rediscoverAlbums → album
|
* - rediscoverAlbums → album
|
||||||
* - rediscoverArtists → artist
|
* - rediscoverArtists → artist
|
||||||
* - mostPlayedTracks → track
|
* - mostPlayedTracks → track
|
||||||
* - lastPlayedArtists → artist
|
* - lastPlayedArtists → artist
|
||||||
|
* - youMightLikeAlbums → album
|
||||||
|
* - youMightLikeArtists → artist
|
||||||
*
|
*
|
||||||
* All slices default to empty so missing keys don't throw on decode.
|
* All slices default to empty so missing keys don't throw on decode —
|
||||||
|
* which also keeps the client safe against older servers that don't
|
||||||
|
* emit the you-might-like sections yet.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HomeIndexWire(
|
data class HomeIndexWire(
|
||||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
|||||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||||
|
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
||||||
|
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ data class MyProfileWire(
|
|||||||
@SerialName("display_name") val displayName: String? = null,
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val email: String? = null,
|
val email: String? = null,
|
||||||
@SerialName("is_admin") val isAdmin: Boolean = false,
|
@SerialName("is_admin") val isAdmin: Boolean = false,
|
||||||
|
@SerialName("debug_mode_enabled") val debugModeEnabled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
|
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable<AdminTagSources> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
AdminTagSourcesScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
|
@Serializable data object AdminTagSources
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
-49
@@ -10,14 +10,20 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -39,21 +45,31 @@ import timber.log.Timber
|
|||||||
* wrapped ExoPlayer stays paused at the position it had when the
|
* wrapped ExoPlayer stays paused at the position it had when the
|
||||||
* route was selected.
|
* route was selected.
|
||||||
*
|
*
|
||||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||||
* factory wraps that callback into a SharedFlow consumed by the
|
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||||
* NowPlaying surface as a snackbar.
|
* (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
|
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||||
* 1-based Track index from GetPositionInfo.
|
* 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(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val castNetworkLock: CastNetworkLock,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -107,6 +123,20 @@ class MinstrelForwardingPlayer(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
// When the phone's own network recovers, reconcile a held cast
|
||||||
|
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||||
|
// the renderer kept playing, so one round-trip re-confirms it and
|
||||||
|
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||||
|
// while a route is active matters.
|
||||||
|
var prev = networkStatus.state.value
|
||||||
|
networkStatus.state.collect { next ->
|
||||||
|
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||||
// addObserver requires it. The observer just trySend's to the channel.
|
// addObserver requires it. The observer just trySend's to the channel.
|
||||||
handler.post {
|
handler.post {
|
||||||
@@ -172,13 +202,14 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.play()
|
super.play()
|
||||||
} else {
|
} else {
|
||||||
|
remoteState.setPlayIntent(true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.play() }
|
runCatching { retryTransport { active.avTransport.play() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPlaying()
|
remoteState.applyTransportPlaying()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,13 +224,14 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.pause()
|
super.pause()
|
||||||
} else {
|
} else {
|
||||||
|
remoteState.setPlayIntent(false)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.pause() }
|
runCatching { retryTransport { active.avTransport.pause() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPaused()
|
remoteState.applyTransportPaused()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,8 +254,8 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = remoteState.trackNumber,
|
trackNumber = remoteState.trackNumber,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.seek(positionMs) }
|
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,11 +285,13 @@ class MinstrelForwardingPlayer(
|
|||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
retryTransport {
|
||||||
if (positionMs > 0L) {
|
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||||
active.avTransport.seek(positionMs)
|
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,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.next() }
|
runCatching { retryTransport { active.avTransport.next() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,11 +337,28 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.previous() }
|
runCatching { retryTransport { active.avTransport.previous() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.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 =
|
override fun getCurrentPosition(): Long =
|
||||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||||
|
|
||||||
@@ -385,11 +436,34 @@ class MinstrelForwardingPlayer(
|
|||||||
super.release()
|
super.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
/**
|
||||||
pollJob?.cancel()
|
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||||
remoteState.applyError(t)
|
* first socket I/O for a second or two; a retry lets the command land once
|
||||||
handler.post { onDrop(active.routeName) }
|
* 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?) {
|
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||||
@@ -402,6 +476,11 @@ class MinstrelForwardingPlayer(
|
|||||||
lastNotifiedTrackIdx = -1
|
lastNotifiedTrackIdx = -1
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||||
|
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||||
|
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||||
|
// without this the radio power-saves on a locked screen and the
|
||||||
|
// poll below starves -- see [CastNetworkLock].
|
||||||
|
castNetworkLock.acquire()
|
||||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||||
// simultaneously with the remote renderer. handler.post targets the
|
// simultaneously with the remote renderer. handler.post targets the
|
||||||
// application looper, so this runs on the same thread that processes
|
// application looper, so this runs on the same thread that processes
|
||||||
@@ -411,20 +490,26 @@ class MinstrelForwardingPlayer(
|
|||||||
handler.post { delegate.pause() }
|
handler.post { delegate.pause() }
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
pollJob = scope.launch { pollLoop(active) }
|
||||||
} else {
|
} else {
|
||||||
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
|
var networkDropSuppressed = false
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
networkDropSuppressed = false
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||||
handler.post { onDrop(active.routeName) }
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
return
|
handler.post { onDrop(active.routeName) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||||
}
|
}
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// resume). Whichever wins continues to the next pollOnce.
|
||||||
@@ -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
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and sync the local cursor to the track the renderer is
|
||||||
* index when not in queue load.
|
* actually playing (see [syncLocalCursorToRemote]).
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
@@ -464,7 +563,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
maybeSyncLocalCursor(info.track)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -488,23 +587,95 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
/**
|
||||||
if (holder.target.value != null) return
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
if (sonosTrack <= 0) return
|
* actually playing, so the un-overridden current-item getters
|
||||||
val sonosIdx = sonosTrack - 1
|
* (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 {
|
handler.post {
|
||||||
val localIdx = delegate.currentMediaItemIndex
|
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
if (target != delegate.currentMediaItemIndex) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
"UPnP cursor sync: local=%d -> %d",
|
||||||
localIdx, sonosIdx,
|
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 {
|
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 POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
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
|
// when Sonos's reported Track matches the wrapped player; this only
|
||||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||||
|
// Transport commands retry transient IO failures so a single WiFi
|
||||||
|
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||||
|
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||||
|
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||||
|
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||||
|
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||||
|
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||||
|
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||||
|
* answered and rejected the action -- propagates immediately: the device is
|
||||||
|
* alive, so retrying won't change its verdict. Extracted from
|
||||||
|
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||||
|
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||||
|
* playback to local audio).
|
||||||
|
*/
|
||||||
|
internal suspend fun <T> retryTransientIo(
|
||||||
|
attempts: Int,
|
||||||
|
backoffMs: Long,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
var attempt = 0
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (io: IOException) {
|
||||||
|
attempt += 1
|
||||||
|
if (attempt >= attempts) throw io
|
||||||
|
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||||
|
delay(backoffMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
||||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
private var curLastPositionMs: Long = 0
|
private var curLastPositionMs: Long = 0
|
||||||
private var curDurationMs: Long = 0
|
private var curDurationMs: Long = 0
|
||||||
|
|
||||||
|
// Whether the currently-tracked play is actively playing (vs paused).
|
||||||
|
// onStop reads this: a backgrounded play that's still playing is left
|
||||||
|
// to the live path (the foreground service keeps us alive), while a
|
||||||
|
// paused one is durably closed so a kill can't orphan its open row.
|
||||||
|
private var curPlaying: Boolean = false
|
||||||
|
|
||||||
// Server play_event_id when the live play_started succeeded;
|
// Server play_event_id when the live play_started succeeded;
|
||||||
// null if start failed/offline — then the close is captured into
|
// null if start failed/offline — then the close is captured into
|
||||||
// the offline mutation queue instead of a live ended/skipped.
|
// the offline mutation queue instead of a live ended/skipped.
|
||||||
private var openPlayEventId: String? = null
|
private var openPlayEventId: String? = null
|
||||||
|
|
||||||
|
// True from beginTrack until startLive's play_started resolves. When a
|
||||||
|
// close races an in-flight start (id still null), the server row is
|
||||||
|
// being created but we've lost its id — we must NOT also enqueue an
|
||||||
|
// offline play (that would duplicate); the server auto-closes the
|
||||||
|
// orphan on the next play_started instead.
|
||||||
|
private var startInFlight: Boolean = false
|
||||||
|
|
||||||
private var prevTrackId: String? = null
|
private var prevTrackId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Track-change → close the prior tracked play.
|
// Track-change → close the prior tracked play.
|
||||||
if (trackId != prevTrackId && curTrackId != null) {
|
if (trackId != prevTrackId && curTrackId != null) {
|
||||||
closeCurrent(viaOffline = false)
|
closeCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entered playing for a new track → begin tracking it.
|
// Entered playing for a new track → begin tracking it.
|
||||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
if (curTrackId != null && trackId == curTrackId) {
|
if (curTrackId != null && trackId == curTrackId) {
|
||||||
curLastPositionMs = positionMs
|
curLastPositionMs = positionMs
|
||||||
if (durationMs > 0) curDurationMs = durationMs
|
if (durationMs > 0) curDurationMs = durationMs
|
||||||
|
curPlaying = playing
|
||||||
}
|
}
|
||||||
|
|
||||||
prevTrackId = trackId
|
prevTrackId = trackId
|
||||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
curSource = source
|
curSource = source
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = durationMs
|
curDurationMs = durationMs
|
||||||
|
curPlaying = true
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
|
startInFlight = true
|
||||||
startLive(trackId = trackId, source = source)
|
startLive(trackId = trackId, source = source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLive(trackId: String, source: String?) {
|
private fun startLive(trackId: String, source: String?) {
|
||||||
val cid = resolveClientId() ?: return
|
val cid = resolveClientId()
|
||||||
|
if (cid == null) {
|
||||||
|
startInFlight = false
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val response = api.playStarted(
|
val response = api.playStarted(
|
||||||
@@ -134,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -144,56 +166,72 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Offline / flaky — openPlayEventId stays null; close
|
// Offline / flaky — openPlayEventId stays null; close
|
||||||
// path will enqueue the completed play for replay.
|
// path will enqueue the completed play for replay.
|
||||||
|
} finally {
|
||||||
|
startInFlight = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closeCurrent(viaOffline: Boolean) {
|
/**
|
||||||
|
* Closes the tracked play. Routes by whether the server already has an
|
||||||
|
* open row, NOT by caller context — this is what keeps background /
|
||||||
|
* track-change / failure paths from each minting a second row:
|
||||||
|
* - open server row (id) → close it by id; on failure enqueue a durable
|
||||||
|
* close-by-id (never an offline play).
|
||||||
|
* - start in flight (no id yet) → leave it; the server auto-closes the
|
||||||
|
* orphan on the next play_started. Enqueuing offline here would
|
||||||
|
* duplicate the in-flight row.
|
||||||
|
* - no server row at all → record the whole play offline.
|
||||||
|
*
|
||||||
|
* Always uses the actual position played; the server's skip rule
|
||||||
|
* (50%/30s defaults) classifies was_skipped from it.
|
||||||
|
*/
|
||||||
|
private fun closeCurrent() {
|
||||||
val trackId = curTrackId
|
val trackId = curTrackId
|
||||||
val startedAt = curStartedAt
|
val startedAt = curStartedAt
|
||||||
if (trackId == null || startedAt == null) {
|
if (trackId == null || startedAt == null) {
|
||||||
resetCurrent()
|
resetCurrent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Always send the actual position played to; the server's
|
|
||||||
// skip rule (50%/30s defaults, configurable) classifies
|
|
||||||
// was_skipped from this. If the track ran to completion the
|
|
||||||
// last observed position is ~= duration, which gives ratio ~1
|
|
||||||
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
|
||||||
// rounding the prior shape did is no longer needed — the
|
|
||||||
// server's threshold is the source of truth.
|
|
||||||
val durationMs = curLastPositionMs
|
val durationMs = curLastPositionMs
|
||||||
val source = curSource
|
val source = curSource
|
||||||
val id = openPlayEventId
|
val id = openPlayEventId
|
||||||
|
val inFlight = startInFlight
|
||||||
if (!viaOffline && id != null) {
|
when {
|
||||||
scope.launch {
|
id != null -> closeById(id, durationMs)
|
||||||
try {
|
inFlight -> Unit
|
||||||
api.playEnded(
|
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||||
PlayEndedRequest(
|
|
||||||
playEventId = id,
|
|
||||||
durationPlayedMs = durationMs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
|
||||||
}
|
}
|
||||||
resetCurrent()
|
resetCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeById(id: String, durationMs: Long) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
api.playEnded(
|
||||||
|
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
||||||
|
)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun resetCurrent() {
|
private fun resetCurrent() {
|
||||||
curTrackId = null
|
curTrackId = null
|
||||||
curStartedAt = null
|
curStartedAt = null
|
||||||
curSource = null
|
curSource = null
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = 0
|
curDurationMs = 0
|
||||||
|
curPlaying = false
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
|
startInFlight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueOffline(
|
private fun enqueueOffline(
|
||||||
@@ -211,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -231,9 +270,24 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||||
|
// configuration later if the metrics trend view shows it matters.
|
||||||
|
private const val DEVICE_CLASS = "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
if (curTrackId != null) {
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
closeCurrent(viaOffline = true)
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
|
private val playlists: PlaylistsRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
private var lastEvaluatedItemIndex: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||||
|
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||||
|
* reset when any track loads with real audio.
|
||||||
|
*/
|
||||||
|
private var selfHealAttempts = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||||
|
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||||
|
* playing our queue, we sync the local cursor to the renderer's current
|
||||||
|
* track/position so skip/seek and the UI map onto its live session — the
|
||||||
|
* renderer keeps the audio, the local player stays paused. Unlike
|
||||||
|
* [seekToIndex] this issues no play().
|
||||||
|
*/
|
||||||
|
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the queue with [tracks] starting at [initialIndex].
|
* Replace the queue with [tracks] starting at [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||||
@@ -264,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
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
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
@@ -337,7 +392,12 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) 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(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
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 ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -403,6 +515,17 @@ class PlayerController @Inject constructor(
|
|||||||
detail = error.message,
|
detail = error.message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A failed load leaves the player IDLE; advance past the bad
|
||||||
|
// track and re-prepare so one unplayable item doesn't strand
|
||||||
|
// playback (mirrors the zero_duration skip). At the end of a
|
||||||
|
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||||
|
// source before stopping.
|
||||||
|
if (controller.hasNextMediaItem()) {
|
||||||
|
controller.seekToNextMediaItem()
|
||||||
|
controller.prepare()
|
||||||
|
} else {
|
||||||
|
selfHealOrStop(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(
|
override fun onMediaItemTransition(
|
||||||
@@ -506,18 +629,19 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
* queue index + current track are owned solely by [onEvents], which reads
|
||||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
* the authoritative delegate cursor: during a cast,
|
||||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
* the track the renderer is actually playing (matched by identity), so there
|
||||||
* wrapped player as defense in depth.
|
* 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) {
|
private fun tickPositionPoll(controller: MediaController) {
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
val upnpActive = activeUpnpHolder.active.value != null
|
||||||
resolvePendingTransport(controller, upnpActive)
|
resolvePendingTransport(controller, upnpActive)
|
||||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition = if (upnpActive) {
|
val effectivePosition = if (upnpActive) {
|
||||||
@@ -525,27 +649,29 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
val idx = controller.currentMediaItemIndex
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
val newDur = effectiveDuration(
|
||||||
upnpActive,
|
upnpActive,
|
||||||
remoteState.durationMs,
|
remoteState.durationMs,
|
||||||
controller.duration,
|
controller.duration,
|
||||||
desiredIdx = desiredIdx,
|
desiredIdx = idx,
|
||||||
controllerIdx = controller.currentMediaItemIndex,
|
controllerIdx = idx,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
// Track adjustments are forward-only AND suppressed while a user
|
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||||
// transport press is pending Sonos confirmation. Together those keep
|
current.positionMs != newPos ||
|
||||||
// either direction of user input from being undone by a stale poll.
|
current.durationMs != newDur ||
|
||||||
val trackChanged = !pendingTransport &&
|
current.bufferedPositionMs != newBuf
|
||||||
desiredIdx > current.queueIndex &&
|
if (somethingChanged) {
|
||||||
desiredIdx in queueRefs.indices
|
uiStateInternal.value = current.copy(
|
||||||
publishTickIfChanged(
|
isPlaying = effectiveIsPlaying,
|
||||||
current, trackChanged, desiredIdx,
|
positionMs = newPos,
|
||||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
durationMs = newDur,
|
||||||
)
|
bufferedPositionMs = newBuf,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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 ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// 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.
|
// they don't need this poll.
|
||||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||||
|
// the next successful play) so a still-broken refresh can't loop.
|
||||||
|
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
|||||||
delegate = exo,
|
delegate = exo,
|
||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
|
castNetworkLock = CastNetworkLock(context),
|
||||||
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
|||||||
* Updates flow in from:
|
* Updates flow in from:
|
||||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||||
* - 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
|
* 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
|
@Singleton
|
||||||
class RemotePlayerState @Inject constructor() {
|
class RemotePlayerState @Inject constructor() {
|
||||||
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
@Volatile var lastError: Throwable? = null; private set
|
@Volatile var lastError: Throwable? = null; private set
|
||||||
@Volatile var trackNumber: Int = 0; private set
|
@Volatile var trackNumber: Int = 0; private set
|
||||||
|
|
||||||
|
// The operator's last explicit transport intent (play=true, pause=false),
|
||||||
|
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||||
|
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||||
|
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||||
|
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||||
|
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||||
|
|
||||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||||
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||||
|
|
||||||
fun applyError(t: Throwable) {
|
fun applyError(t: Throwable) {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
lastError = t
|
lastError = t
|
||||||
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
lastPlayIntent = false
|
||||||
currentTrackUri = ""
|
currentTrackUri = ""
|
||||||
lastError = null
|
lastError = null
|
||||||
consecutivePollFailures = 0
|
consecutivePollFailures = 0
|
||||||
|
|||||||
@@ -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.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -147,6 +150,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
// can run the longest-common-prefix / common-suffix diff.
|
// can run the longest-common-prefix / common-suffix diff.
|
||||||
private var lastSyncedQueueIds: List<String>? = null
|
private var lastSyncedQueueIds: List<String>? = null
|
||||||
|
|
||||||
|
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||||
|
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||||
|
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||||
|
// See idleRevertAction for the arm/cancel decision.
|
||||||
|
private var idleRevertJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Two-arg addCallback registers with no discovery flag —
|
// Two-arg addCallback registers with no discovery flag —
|
||||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||||
@@ -155,7 +164,8 @@ class OutputPickerController @Inject constructor(
|
|||||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||||
mediaRouter.addCallback(selector, callback)
|
mediaRouter.addCallback(selector, callback)
|
||||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||||
// dropped (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
|
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||||
// path but skips the Stop SOAP since the device is already unreachable.
|
// path but skips the Stop SOAP since the device is already unreachable.
|
||||||
@@ -163,6 +173,79 @@ class OutputPickerController @Inject constructor(
|
|||||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
|
scope.launch { observeIdleRevertWhileUpnp() }
|
||||||
|
scope.launch { observeSelectedRouteDisappearance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||||
|
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||||
|
* to it. Without this the selection could pin to a ghost route, so a later
|
||||||
|
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||||
|
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||||
|
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||||
|
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||||
|
*/
|
||||||
|
private suspend fun observeSelectedRouteDisappearance() {
|
||||||
|
upnpDiscovery.routes.collect { routes ->
|
||||||
|
val selected = selectedUpnpRouteIdInternal.value
|
||||||
|
val present = routes.any { it.id == selected }
|
||||||
|
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||||
|
if (selected != null && !present && !casting) {
|
||||||
|
Timber.w(
|
||||||
|
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||||
|
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||||
|
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||||
|
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||||
|
*/
|
||||||
|
private suspend fun observeIdleRevertWhileUpnp() {
|
||||||
|
playerController.uiState.collect { state ->
|
||||||
|
val engaged = activeUpnpHolder.active.value != null ||
|
||||||
|
activeUpnpHolder.target.value != null
|
||||||
|
val armed = idleRevertJob?.isActive == true
|
||||||
|
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||||
|
IdleRevertAction.ARM ->
|
||||||
|
idleRevertJob = scope.launch {
|
||||||
|
delay(IDLE_REVERT_MS)
|
||||||
|
revertToPhoneOnIdle()
|
||||||
|
}
|
||||||
|
IdleRevertAction.CANCEL -> {
|
||||||
|
idleRevertJob?.cancel()
|
||||||
|
idleRevertJob = null
|
||||||
|
}
|
||||||
|
IdleRevertAction.IGNORE -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||||
|
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||||
|
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||||
|
* seek local ExoPlayer to the last remote position. No play(): the
|
||||||
|
* operator was paused, so we revert routing silently. Guarded by the
|
||||||
|
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||||
|
*/
|
||||||
|
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||||
|
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||||
|
val active = activeUpnpHolder.active.value
|
||||||
|
val capturedPositionMs = remoteState.positionMs
|
||||||
|
runCatching { active?.avTransport?.stop() }
|
||||||
|
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||||
|
activeUpnpHolder.set(null)
|
||||||
|
activeUpnpHolder.setTarget(null)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
idleRevertJob = null
|
||||||
|
playerController.seekTo(capturedPositionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,16 +448,19 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||||
* threshold or SOAP exception). Captures the last remote position +
|
* consecutive-failure threshold tripping). Captures the last remote
|
||||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||||
* The snackbar is handled independently by the NowPlaying surface
|
* The snackbar is handled independently by the NowPlaying surface
|
||||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||||
*/
|
*/
|
||||||
private fun handleRemoteDrop() {
|
private fun handleRemoteDrop() {
|
||||||
val capturedPositionMs = remoteState.positionMs
|
val capturedPositionMs = remoteState.positionMs
|
||||||
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.set(null)
|
||||||
activeUpnpHolder.setTarget(null)
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
@@ -482,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
|||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
// If the renderer is already playing this exact queue (a "stray"
|
||||||
|
// session we got disconnected from), attach to it in place instead of
|
||||||
|
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||||
|
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||||
|
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
// silently while target is set but active is null -- the user's premature
|
// silently while target is set but active is null -- the user's premature
|
||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
@@ -497,6 +590,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
rendering = rendering,
|
rendering = rendering,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Selecting a route loads the queue and Plays on the renderer, so
|
||||||
|
// the standing intent is "play" -- a later drop should resume local.
|
||||||
|
remoteState.setPlayIntent(true)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
@@ -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(
|
private suspend fun loadQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
@@ -640,5 +832,10 @@ class OutputPickerController @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
const val EXTEND_THROTTLE_MS = 50L
|
||||||
|
|
||||||
|
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||||
|
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||||
|
// a later "tap play" do nothing.
|
||||||
|
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -133,7 +133,7 @@ private fun RouteRow(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitle = route.description ?: defaultSubtitle(route)
|
val subtitle = route.description ?: defaultSubtitle(route, isSelected)
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.BuiltIn -> "Phone speaker"
|
||||||
OutputRoute.Kind.Wired -> "Wired"
|
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.Cast -> "Cast"
|
||||||
OutputRoute.Kind.Other -> "Available"
|
OutputRoute.Kind.Other -> "Available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ data class OutputRoute(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val kind: Kind,
|
val kind: Kind,
|
||||||
val protocol: Protocol,
|
val protocol: Protocol,
|
||||||
val isConnected: Boolean,
|
|
||||||
) {
|
) {
|
||||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||||
|
|
||||||
@@ -43,9 +42,11 @@ data class OutputRoute(
|
|||||||
/**
|
/**
|
||||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||||
* device types fall through to [Kind.Other]. The
|
* device types fall through to [Kind.Other]. "Active" is NOT an
|
||||||
* `connectionState` proxy is good enough for the chip's
|
* intrinsic of the route — the caller derives it by comparing to
|
||||||
* "Connected"/"Available" subtitle.
|
* 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 {
|
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||||
val kind = when (route.deviceType) {
|
val kind = when (route.deviceType) {
|
||||||
@@ -58,15 +59,12 @@ data class OutputRoute(
|
|||||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||||
else -> Kind.Other
|
else -> Kind.Other
|
||||||
}
|
}
|
||||||
val connected =
|
|
||||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
|
||||||
return OutputRoute(
|
return OutputRoute(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
description = route.description,
|
description = route.description,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
protocol = Protocol.SYSTEM,
|
protocol = Protocol.SYSTEM,
|
||||||
isConnected = connected,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +74,10 @@ data class OutputRoute(
|
|||||||
* network speakers into the same `OutputPickerController`
|
* network speakers into the same `OutputPickerController`
|
||||||
* routes stream the system routes come through.
|
* routes stream the system routes come through.
|
||||||
*
|
*
|
||||||
* `isConnected = false` because UPnP devices have no
|
* UPnP devices have no MediaRouter connection-state concept —
|
||||||
* MediaRouter connection-state concept — they're always
|
* they're always "available" on the LAN, and the picker's
|
||||||
* "available" on the LAN, and the picker's selected-route
|
* selected-route rendering handles the "currently playing"
|
||||||
* rendering handles the "currently playing" indicator.
|
* indicator.
|
||||||
*
|
*
|
||||||
* Subtitle is `manufacturer modelName` joined by a single
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
* space, falling back to "Network speaker" when both fields
|
* space, falling back to "Network speaker" when both fields
|
||||||
@@ -96,7 +94,6 @@ data class OutputRoute(
|
|||||||
description = description,
|
description = description,
|
||||||
kind = Kind.Other,
|
kind = Kind.Other,
|
||||||
protocol = Protocol.UPNP,
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -17,6 +18,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -45,9 +48,24 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
private val ssdp = SsdpDiscovery(context)
|
private val ssdp = SsdpDiscovery(context)
|
||||||
|
|
||||||
|
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||||
|
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||||
|
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||||
|
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||||
|
// dispatcher stay shared with the app client.
|
||||||
|
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||||
|
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||||
|
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||||
|
// (and can't pin a stale selection) instead of lingering for the process
|
||||||
|
// lifetime -- upsert only ever added before.
|
||||||
|
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -68,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* that just opened).
|
* that just opened).
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
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? {
|
fun transportFor(routeId: String): AVTransportClient? {
|
||||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||||
return AVTransportClient(
|
return AVTransportClient(
|
||||||
loggingSoapClient(okHttp, route.name),
|
loggingSoapClient(controlHttp, route.name),
|
||||||
route.avTransportControlUrl,
|
route.avTransportControlUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertRoute(route: UpnpRoute) {
|
private fun upsertRoute(route: UpnpRoute) {
|
||||||
|
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||||
val current = routesInternal.value
|
val current = routesInternal.value
|
||||||
val idx = current.indexOfFirst { it.id == route.id }
|
val idx = current.indexOfFirst { it.id == route.id }
|
||||||
routesInternal.value = if (idx < 0) {
|
routesInternal.value = if (idx < 0) {
|
||||||
@@ -225,6 +268,16 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// devices append. Anchored to end-of-string so it never eats
|
// devices append. Anchored to end-of-string so it never eats
|
||||||
// a legitimate parenthetical inside a name.
|
// a legitimate parenthetical inside a name.
|
||||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||||
|
|
||||||
|
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||||
|
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||||
|
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||||
|
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||||
|
|
||||||
|
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||||
|
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||||
|
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||||
|
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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.PlayerController
|
||||||
import com.fabledsword.minstrel.player.PlayerUiState
|
import com.fabledsword.minstrel.player.PlayerUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
|
private val likes: LikesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
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 play() = controller.play()
|
||||||
fun pause() = controller.pause()
|
fun pause() = controller.pause()
|
||||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
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
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -20,22 +21,24 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.Lucide
|
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.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,16 +47,35 @@ fun QueueScreen(
|
|||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -67,87 +89,106 @@ fun QueueScreen(
|
|||||||
QueueList(
|
QueueList(
|
||||||
tracks = state.queue,
|
tracks = state.queue,
|
||||||
currentIndex = state.queueIndex,
|
currentIndex = state.queueIndex,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
QueueRow(
|
)
|
||||||
track = track,
|
val scope = rememberCoroutineScope()
|
||||||
isCurrent = index == currentIndex,
|
|
||||||
onClick = { onJumpTo(index) },
|
// 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
|
||||||
HorizontalDivider()
|
// 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
|
@Composable
|
||||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
private fun JumpToCurrentPill(
|
||||||
val highlight = if (isCurrent) {
|
visible: Boolean,
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
onClick: () -> Unit,
|
||||||
} else {
|
modifier: Modifier = Modifier,
|
||||||
Color.Transparent
|
) {
|
||||||
}
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
Row(
|
FilledTonalButton(onClick = onClick) {
|
||||||
modifier = Modifier
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
.fillMaxWidth()
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
.background(highlight)
|
Text("Jump to current")
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
if (isCurrent) {
|
|
||||||
Icon(
|
|
||||||
Lucide.Volume2,
|
|
||||||
contentDescription = "Now playing",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = track.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = queueSubtitle(track)
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (track.durationSec > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatDuration(track.durationSec),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "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
|
||||||
|
|||||||
+3
-3
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||||
playlistTrackDao.deleteByPlaylist(id)
|
playlistTrackDao.replacePlaylistTracks(
|
||||||
playlistTrackDao.upsertAll(
|
playlistId = id,
|
||||||
wire.tracks.mapNotNull { row ->
|
rows = wire.tracks.mapNotNull { row ->
|
||||||
row.trackId?.let { trackId ->
|
row.trackId?.let { trackId ->
|
||||||
CachedPlaylistTrackEntity(
|
CachedPlaylistTrackEntity(
|
||||||
playlistId = id,
|
playlistId = id,
|
||||||
|
|||||||
+85
-30
@@ -28,15 +28,17 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -56,6 +58,8 @@ import com.composables.icons.lucide.Play
|
|||||||
import com.composables.icons.lucide.RefreshCw
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
@@ -72,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -113,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -139,6 +145,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||||
|
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||||
|
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||||
|
* re-resolves the variant via [reloadRebuilt].
|
||||||
|
*/
|
||||||
|
private val staleInternal = MutableStateFlow(false)
|
||||||
|
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTrackIds()
|
likes.observeLikedTrackIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -154,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.collect { handlePlaylistEvent(it) }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, instead of waiting for the Retry tap.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,6 +186,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
* ignored — the playlists-list screen handles those.
|
* ignored — the playlists-list screen handles those.
|
||||||
*/
|
*/
|
||||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||||
|
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||||
|
// user's system mixes at once) — handle it before the per-id filter.
|
||||||
|
if (event.kind == "playlist.system_rebuilt") {
|
||||||
|
markStaleIfSystem()
|
||||||
|
return
|
||||||
|
}
|
||||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||||
if (eventPlaylistId != playlistId) return
|
if (eventPlaylistId != playlistId) return
|
||||||
when (event.kind) {
|
when (event.kind) {
|
||||||
@@ -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) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
viewModelScope.launch {
|
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). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val refs = tracks.toPlayableTrackRefs()
|
val refs = tracks.toPlayableTrackRefs()
|
||||||
@@ -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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(currentTitle(state)) },
|
title = { Text(currentTitle(state)) },
|
||||||
@@ -301,7 +379,11 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load playlist",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
@@ -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 ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+26
-9
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+62
-1
@@ -3,13 +3,17 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -20,10 +24,15 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||||
|
|
||||||
|
// Cadence for in-flight request polling — matches the web client (#369):
|
||||||
|
// fast enough to feel live, slow enough not to hammer the server.
|
||||||
|
private const val POLL_INTERVAL_MS = 12_000L
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -31,10 +40,21 @@ class RequestsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// SSE push: reconciler completions arrive here; reload silently so the
|
||||||
|
// row updates in place rather than flashing the loading spinner.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { silentReload() }
|
||||||
|
}
|
||||||
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
|
// Screen-level auto-recovery (issue #1245): a load that failed while
|
||||||
|
// the server was unreachable heals the moment health returns, without
|
||||||
|
// waiting for the next poll tick.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is UiState.Error) silentReload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +74,47 @@ class RequestsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
||||||
|
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
||||||
|
* when nothing is in-flight — complements the SSE push so progress stays
|
||||||
|
* live even if the event stream drops.
|
||||||
|
*/
|
||||||
|
private suspend fun pollWhileInFlight() {
|
||||||
|
while (true) {
|
||||||
|
// Also retry from a failed load (issue #1245): from UiState.Error
|
||||||
|
// the in-flight predicate can never become true, so without this
|
||||||
|
// the poll never self-heals the screen — e.g. after a transient
|
||||||
|
// 500 that health monitoring (transport-level) never saw.
|
||||||
|
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
||||||
|
silentReload()
|
||||||
|
}
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasInFlightRequest(): Boolean {
|
||||||
|
val state = internal.value
|
||||||
|
return state is UiState.Success &&
|
||||||
|
state.data.any { it.status == RequestStatus.APPROVED }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload the list in place without flipping to Loading — used by the poll
|
||||||
|
* loop and the SSE collector so visible rows update without a spinner
|
||||||
|
* flash. A transient failure keeps the current view; the next reload wins.
|
||||||
|
*/
|
||||||
|
private suspend fun silentReload() {
|
||||||
|
try {
|
||||||
|
val rows = repository.listMine()
|
||||||
|
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Keep the current view; the next poll / SSE / pull reconciles.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optimistically removes [id] from the success list so the row
|
* Optimistically removes [id] from the success list so the row
|
||||||
* disappears immediately; on server-side success the row is
|
* disappears immediately; on server-side success the row is
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
is SearchResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SearchResultsState.Loaded -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
runSearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a search that failed
|
||||||
|
// while the server was unreachable re-runs when health returns —
|
||||||
|
// the queryFlow pipeline can't, since the text hasn't changed.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.results is SearchResultsState.Error) retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
setQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the current query after a failure. Bypasses the queryFlow
|
||||||
|
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
||||||
|
* without this, a transient error pins "Search failed" until the
|
||||||
|
* user edits the query.
|
||||||
|
*/
|
||||||
|
fun retry() {
|
||||||
|
val q = internal.value.query.trim()
|
||||||
|
if (q.isEmpty()) return
|
||||||
|
viewModelScope.launch { runSearch(q) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tapping a search result builds a queue from the full visible
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
+37
-21
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||||
|
import com.fabledsword.minstrel.update.data.message
|
||||||
|
import com.fabledsword.minstrel.update.data.stage
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of three terminal states the Check-for-updates button surfaces.
|
* One of three terminal states the Check-for-updates button surfaces.
|
||||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||||
* system installer via [ApkInstaller].
|
* installs it via [ApkInstaller].
|
||||||
*/
|
*/
|
||||||
sealed interface UpdateCheckResult {
|
sealed interface UpdateCheckResult {
|
||||||
data object Idle : UpdateCheckResult
|
data object Idle : UpdateCheckResult
|
||||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
|||||||
data class AboutUiState(
|
data class AboutUiState(
|
||||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val isInstalling: Boolean = false,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||||
)
|
)
|
||||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
|||||||
* [UpdateRepository.getLatest], compares versus the build's
|
* [UpdateRepository.getLatest], compares versus the build's
|
||||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||||
* When an update is available, [install] downloads the APK via
|
* When an update is available, [install] downloads the APK via
|
||||||
* [ApkInstaller] and hands it to the system installer — routing the
|
* [ApkInstaller] and installs it — routing the user to the "install
|
||||||
* user to the "install unknown apps" settings page first when that
|
* unknown apps" settings page first when that permission hasn't been
|
||||||
* permission hasn't been granted.
|
* granted.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AboutCardViewModel @Inject constructor(
|
class AboutCardViewModel @Inject constructor(
|
||||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (internal.value.isInstalling) return
|
if (internal.value.installStage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
internal.update {
|
internal.update {
|
||||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
internal.update {
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||||
.onSuccess { apk ->
|
}
|
||||||
installer.launchInstall(apk)
|
val apk = download(info.apkUrl)
|
||||||
internal.update { it.copy(isInstalling = false) }
|
if (apk != null) {
|
||||||
}
|
// The install half now suspends on the platform's verdict, so it
|
||||||
.onFailure { e ->
|
// gets its own stage — reporting "Downloading…" through it would
|
||||||
val why = ErrorCopy.fromThrowable(e)
|
// be a lie once a confirm dialog is on screen.
|
||||||
internal.update {
|
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||||
it.copy(
|
val outcome = installer.install(apk)
|
||||||
isInstalling = false,
|
internal.update {
|
||||||
installMessage = "Couldn't download update: $why",
|
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun download(apkUrl: String): File? =
|
||||||
|
runCatching { installer.downloadApk(apkUrl) }
|
||||||
|
.onFailure { e ->
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
installStage = InstallStage.ERROR,
|
||||||
|
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -57,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
|
|||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.theme.ThemeMode
|
import com.fabledsword.minstrel.theme.ThemeMode
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -94,6 +97,7 @@ fun SettingsScreen(
|
|||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
|
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,7 @@ private fun SettingsList(
|
|||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
|
onToggleDiagnostics: (Boolean) -> Unit,
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -148,6 +153,12 @@ private fun SettingsList(
|
|||||||
ProfileCard()
|
ProfileCard()
|
||||||
PasswordCard()
|
PasswordCard()
|
||||||
ListenBrainzCard()
|
ListenBrainzCard()
|
||||||
|
if (state.diagnosticsEnabledByAdmin) {
|
||||||
|
DiagnosticsCard(
|
||||||
|
optOut = state.diagnosticsOptOut,
|
||||||
|
onToggle = onToggleDiagnostics,
|
||||||
|
)
|
||||||
|
}
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@@ -262,6 +273,46 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shown only while an admin has enabled debug mode on this account. Lets
|
||||||
|
// the user turn reporting OFF on this device (battery/privacy) without
|
||||||
|
// touching the account flag. Checked = reporting on = NOT opted out.
|
||||||
|
@Composable
|
||||||
|
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Diagnostics",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Send diagnostic reports",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your admin enabled debug mode to investigate an " +
|
||||||
|
"issue. This device streams connectivity and playback " +
|
||||||
|
"diagnostics. Turn off any time.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Switch(
|
||||||
|
checked = !optOut,
|
||||||
|
onCheckedChange = { checked -> onToggle(!checked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
|
||||||
@@ -332,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
enabled = !state.isChecking && !state.isInstalling,
|
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (state.isChecking) {
|
if (state.isChecking) {
|
||||||
@@ -344,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
if (available != null) {
|
if (available != null) {
|
||||||
InstallButton(
|
InstallButton(
|
||||||
version = available.info.version,
|
version = available.info.version,
|
||||||
isInstalling = state.isInstalling,
|
stage = state.installStage,
|
||||||
onClick = { viewModel.install(available.info) },
|
onClick = { viewModel.install(available.info) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = !isInstalling,
|
enabled = !stage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (isInstalling) {
|
if (stage.isBusy()) {
|
||||||
ButtonSpinner()
|
ButtonSpinner()
|
||||||
}
|
}
|
||||||
Text(if (isInstalling) "Downloading…" else "Install $version")
|
Text(
|
||||||
|
when (stage) {
|
||||||
|
InstallStage.DOWNLOADING -> "Downloading…"
|
||||||
|
InstallStage.INSTALLING -> "Installing…"
|
||||||
|
else -> "Install $version"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -19,12 +19,15 @@ data class SettingsState(
|
|||||||
val isAdmin: Boolean = false,
|
val isAdmin: Boolean = false,
|
||||||
val isSigningOut: Boolean = false,
|
val isSigningOut: Boolean = false,
|
||||||
val signedOut: Boolean = false,
|
val signedOut: Boolean = false,
|
||||||
|
// M9: account debug flag (admin-set) + this device's local opt-out.
|
||||||
|
val diagnosticsEnabledByAdmin: Boolean = false,
|
||||||
|
val diagnosticsOptOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -41,19 +44,27 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
authStore.baseUrl,
|
authStore.baseUrl,
|
||||||
authController.currentUser,
|
authController.currentUser,
|
||||||
|
authStore.diagnosticsOptOut,
|
||||||
transient,
|
transient,
|
||||||
) { url, user, t ->
|
) { url, user, optOut, t ->
|
||||||
SettingsState(
|
SettingsState(
|
||||||
serverUrl = url,
|
serverUrl = url,
|
||||||
username = user?.username.orEmpty(),
|
username = user?.username.orEmpty(),
|
||||||
isAdmin = user?.isAdmin == true,
|
isAdmin = user?.isAdmin == true,
|
||||||
isSigningOut = t.isSigningOut,
|
isSigningOut = t.isSigningOut,
|
||||||
signedOut = t.signedOut,
|
signedOut = t.signedOut,
|
||||||
|
diagnosticsEnabledByAdmin = user?.debugModeEnabled == true,
|
||||||
|
diagnosticsOptOut = optOut,
|
||||||
)
|
)
|
||||||
}.collect { internal.value = it }
|
}.collect { internal.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-device opt-out of diagnostics while the account flag is on. */
|
||||||
|
fun setDiagnosticsOptOut(optOut: Boolean) {
|
||||||
|
authStore.setDiagnosticsOptOut(optOut)
|
||||||
|
}
|
||||||
|
|
||||||
fun signOut() {
|
fun signOut() {
|
||||||
if (transient.value.isSigningOut) return
|
if (transient.value.isSigningOut) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
// Once raised, the veil stays up at least this long. Without a floor a
|
||||||
|
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||||
|
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||||
|
|
||||||
|
// The screen must stop changing for this long before the veil lowers.
|
||||||
|
// Every content change re-arms it, so a refresh that lands in stages
|
||||||
|
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||||
|
private const val DEFAULT_QUIET_MS = 700L
|
||||||
|
|
||||||
|
// Hard ceiling on visibility. A refresh that never settles must not
|
||||||
|
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||||
|
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||||
|
|
||||||
|
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||||
|
// that fails on the first try gets another go before the user sees
|
||||||
|
// anything, instead of the veil wiping off over unchanged content.
|
||||||
|
private const val DEFAULT_ATTEMPTS = 3
|
||||||
|
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||||
|
|
||||||
|
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||||
|
data class VeilTimings(
|
||||||
|
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||||
|
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||||
|
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||||
|
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||||
|
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of everything that visibly moves on the veiled screen.
|
||||||
|
*
|
||||||
|
* @param contentKey any value whose equality tracks what's rendered — a
|
||||||
|
* change means the screen moved, and re-arms the quiet timer.
|
||||||
|
* @param hasContent true when real content (not a skeleton or an empty
|
||||||
|
* state) is on screen. The veil waits for this before raising: there's
|
||||||
|
* nothing to hide until there's something to hide.
|
||||||
|
* @param quiescent false while something is still landing (artwork
|
||||||
|
* loading, tiles hydrating). The veil will not lower until this is
|
||||||
|
* true, up to [VeilTimings.maxHoldMs].
|
||||||
|
*/
|
||||||
|
data class VeilSettleState(
|
||||||
|
val contentKey: Any?,
|
||||||
|
val hasContent: Boolean,
|
||||||
|
val quiescent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a finished session did, so callers can report it if they want. */
|
||||||
|
enum class VeilOutcome {
|
||||||
|
/** Content changed, and the veil covered the churn. */
|
||||||
|
CHANGED,
|
||||||
|
|
||||||
|
/** The refresh worked, but nothing on screen moved — already current. */
|
||||||
|
UNCHANGED,
|
||||||
|
|
||||||
|
/** Every attempt failed. */
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session's result, plus whether a user explicitly asked for it.
|
||||||
|
*
|
||||||
|
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||||
|
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||||
|
* changed", while the same outcome from a background check is noise.
|
||||||
|
*/
|
||||||
|
data class VeilSessionResult(
|
||||||
|
val outcome: VeilOutcome,
|
||||||
|
val userInitiated: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives an "updating" overlay from *observed content change and settling*
|
||||||
|
* rather than from a fixed delay.
|
||||||
|
*
|
||||||
|
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||||
|
* lowers while the screen is still moving, because finishing the network
|
||||||
|
* pull is nowhere near the end of the visible work — the pull writes id
|
||||||
|
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||||
|
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||||
|
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||||
|
* (issue #2327).
|
||||||
|
*
|
||||||
|
* So instead: run [work], raise only if the content actually changes, then
|
||||||
|
* hold until [settleSignal] reports the screen has stopped changing for
|
||||||
|
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||||
|
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||||
|
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||||
|
*
|
||||||
|
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||||
|
* already on screen — the common case on a launch over a warm cache —
|
||||||
|
* raises nothing at all, because a veil over an unchanged screen hides
|
||||||
|
* nothing and only delays first paint. The cost is that the veil arrives
|
||||||
|
* one emission after the change, so a single atomic content swap shows
|
||||||
|
* through; everything messier that follows it (tile hydration, then
|
||||||
|
* artwork) still lands behind the veil.
|
||||||
|
*
|
||||||
|
* Overlapping triggers extend the running session instead of racing it,
|
||||||
|
* so the veil stays up continuously rather than lowering and re-raising.
|
||||||
|
*
|
||||||
|
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||||
|
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||||
|
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||||
|
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||||
|
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||||
|
* exactly as before. Callers that want to surface a failure can do it from
|
||||||
|
* [onSessionEnd] instead.
|
||||||
|
*
|
||||||
|
* @param work one refresh attempt; returns true when it succeeded.
|
||||||
|
* @param shouldVeil sampled at session start — "is there cached content
|
||||||
|
* this refresh is about to overwrite?". False means a cold load, where
|
||||||
|
* a skeleton is the right affordance, and the work runs unveiled.
|
||||||
|
* @param onSessionEnd called once per finished session, on the controller's
|
||||||
|
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||||
|
*/
|
||||||
|
class UpdateVeilController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val settleSignal: Flow<VeilSettleState>,
|
||||||
|
private val shouldVeil: suspend () -> Boolean,
|
||||||
|
private val timings: VeilTimings = VeilTimings(),
|
||||||
|
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
private val work: suspend () -> Boolean,
|
||||||
|
) {
|
||||||
|
private val visibleInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True while the veil should be drawn over the screen. */
|
||||||
|
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val finishedInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments as each session ends. Lets a caller wait for "this
|
||||||
|
* refresh is done" without knowing whether a veil ever went up —
|
||||||
|
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||||
|
* refresh never raises one.
|
||||||
|
*/
|
||||||
|
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||||
|
// together) collapses into one follow-up pass, not a queue of them.
|
||||||
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
// Sticky across a conflated burst: conflation drops the older token, so
|
||||||
|
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||||
|
// trigger was the user's, the session still owes them an answer.
|
||||||
|
private val userAsked = AtomicBoolean(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// One consumer, so sessions are serialised by construction: two
|
||||||
|
// triggers can never each own a piece of the veil's state.
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
requests.receive()
|
||||||
|
runSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||||
|
* calls arriving during a session extend it rather than starting a
|
||||||
|
* competing one.
|
||||||
|
*
|
||||||
|
* @param userInitiated true when a person explicitly asked (pull to
|
||||||
|
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||||
|
* through to [onSessionEnd].
|
||||||
|
*/
|
||||||
|
fun request(userInitiated: Boolean = false) {
|
||||||
|
if (userInitiated) userAsked.set(true)
|
||||||
|
requests.trySend(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSession() {
|
||||||
|
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||||
|
// (see [drainWork]) may have been the user's, and they're still owed
|
||||||
|
// an answer for it.
|
||||||
|
val askedAtStart = userAsked.getAndSet(false)
|
||||||
|
if (!shouldVeil()) {
|
||||||
|
// Cold load: the skeleton is the right affordance, so no veil.
|
||||||
|
// Succeeding here did change the screen — from nothing to
|
||||||
|
// something — so it reports CHANGED, never "already up to date".
|
||||||
|
val ok = drainWork()
|
||||||
|
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val raised = CompletableDeferred<Unit>()
|
||||||
|
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||||
|
// Floor and ceiling are measured from the raise, not the request,
|
||||||
|
// so a late raise still gets its full no-flash minimum.
|
||||||
|
val floor = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.minHoldMs)
|
||||||
|
}
|
||||||
|
val ceiling = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.maxHoldMs)
|
||||||
|
visibleInternal.value = false
|
||||||
|
}
|
||||||
|
var succeeded = false
|
||||||
|
try {
|
||||||
|
succeeded = drainWork()
|
||||||
|
// Always wait for the settle, never conditionally on `visible`:
|
||||||
|
// work that finishes without suspending would otherwise reach
|
||||||
|
// here before the raiser has been dispatched, tear the session
|
||||||
|
// down, and leave the churn uncovered. This wait is also what
|
||||||
|
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||||
|
// change?" — a change landing just after the pull returns still
|
||||||
|
// gets seen.
|
||||||
|
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||||
|
// Honour the no-flash minimum before lowering. Deliberately in
|
||||||
|
// the try and not the finally: on cancellation the scope is
|
||||||
|
// going away and nothing will render the veil, so the floor is
|
||||||
|
// pointless there — and a finally that suspends is a finally
|
||||||
|
// that can resist teardown.
|
||||||
|
if (raised.isCompleted) floor.join()
|
||||||
|
} finally {
|
||||||
|
raiser.cancel()
|
||||||
|
ceiling.cancel()
|
||||||
|
floor.cancel()
|
||||||
|
visibleInternal.value = false
|
||||||
|
finish(
|
||||||
|
succeeded = succeeded,
|
||||||
|
changed = raised.isCompleted,
|
||||||
|
userInitiated = askedAtStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the veil the moment the screen's content differs from what was
|
||||||
|
* already on it — and never, if this refresh turns out to be a no-op.
|
||||||
|
*
|
||||||
|
* The baseline is the first state that HAS content, not simply the first
|
||||||
|
* state: over a warm cache the cached rows paint a moment after the
|
||||||
|
* session starts, and treating that first paint as "a change" would veil
|
||||||
|
* every launch, which is the whole thing this avoids.
|
||||||
|
*/
|
||||||
|
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||||
|
val baseline = settleSignal.first { it.hasContent }
|
||||||
|
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||||
|
visibleInternal.value = true
|
||||||
|
raised.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||||
|
val outcome = when {
|
||||||
|
!succeeded -> VeilOutcome.FAILED
|
||||||
|
changed -> VeilOutcome.CHANGED
|
||||||
|
else -> VeilOutcome.UNCHANGED
|
||||||
|
}
|
||||||
|
onSessionEnd(
|
||||||
|
VeilSessionResult(
|
||||||
|
outcome = outcome,
|
||||||
|
// Fold in a mid-session request from the user.
|
||||||
|
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finishedInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the refresh eventually succeeded. */
|
||||||
|
private suspend fun drainWork(): Boolean {
|
||||||
|
var succeeded: Boolean
|
||||||
|
do {
|
||||||
|
succeeded = runWorkWithRetries()
|
||||||
|
// A trigger that arrived mid-session gets folded into this one.
|
||||||
|
} while (requests.tryReceive().isSuccess)
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWorkWithRetries(): Boolean {
|
||||||
|
repeat(timings.attempts) { attempt ->
|
||||||
|
if (work()) return true
|
||||||
|
if (attempt < timings.attempts - 1) {
|
||||||
|
delay(timings.retryBackoffMs * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the screen has been unchanged for
|
||||||
|
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||||
|
*
|
||||||
|
* `debounce` is what makes this hold across a staged update: every
|
||||||
|
* change restarts the window, so the veil lowers only once emissions
|
||||||
|
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||||
|
* still-loading moment and waits for the next lull.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private suspend fun awaitSettled() {
|
||||||
|
settleSignal
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(timings.quietMs)
|
||||||
|
.first { it.quiescent }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the cover-art loads that are currently in flight, so a
|
||||||
|
* screen-level overlay can wait for the artwork to actually land instead
|
||||||
|
* of guessing with a fixed delay.
|
||||||
|
*
|
||||||
|
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||||
|
* hydrated (title, artist, counts all present) and still snap its cover
|
||||||
|
* in a second later, which is exactly the churn the "Updating your
|
||||||
|
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||||
|
* it finishes long before Coil does — so the composition reports it
|
||||||
|
* upward here instead.
|
||||||
|
*
|
||||||
|
* [ServerImage] reports into whatever tracker it finds in
|
||||||
|
* [LocalArtSettleTracker], which means every art surface in the app
|
||||||
|
* participates for free. Only *composed* images are counted, so a
|
||||||
|
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||||
|
* the pop-in a user can actually see.
|
||||||
|
*
|
||||||
|
* Provide one per screen that needs it (typically owned by the
|
||||||
|
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||||
|
*
|
||||||
|
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ArtSettleTracker {
|
||||||
|
private val inFlightInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many on-screen images are still loading. Zero means the
|
||||||
|
* artwork has settled — every composed cover has either drawn or
|
||||||
|
* failed to a fallback.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||||
|
|
||||||
|
fun begin() {
|
||||||
|
inFlightInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun end() {
|
||||||
|
// Floor at zero: a decrement can outlive its increment when a
|
||||||
|
// tile is disposed mid-load and the count must not go negative
|
||||||
|
// and wedge "settled" off forever.
|
||||||
|
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracker [ServerImage] reports load state to, or null on screens
|
||||||
|
* that don't care (the default) — reporting is then a no-op.
|
||||||
|
*/
|
||||||
|
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||||
* `Modifier.align(...)` inside it.
|
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||||
|
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||||
|
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||||
|
* the square's corner, outside the circle, and would otherwise be clipped
|
||||||
|
* away. Only the artwork + background are clipped to [shape].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.size(size),
|
||||||
.size(size)
|
|
||||||
.clip(shape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
Box(
|
||||||
url = url,
|
modifier = Modifier
|
||||||
contentDescription = contentDescription,
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
.clip(shape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
fallback()
|
ServerImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
|||||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||||
|
*
|
||||||
|
* Renders inside a single-item LazyColumn (same rationale as
|
||||||
|
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
||||||
|
* under PullToRefreshBox — both the Retry button and a pull gesture
|
||||||
|
* can recover from the error state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = actionColors.primary,
|
||||||
|
contentColor = actionColors.onAction,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(retryLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
|
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||||
|
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||||
|
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||||
|
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||||
|
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||||
|
private const val FALLBACK_FADE_MS = 220
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank or unresolvable.
|
* is blank/unresolvable, while the image is still loading, and when the
|
||||||
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
|
* which the "You might like" row hits often).
|
||||||
|
*
|
||||||
|
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||||
|
* provides one, so a screen-level overlay can wait for artwork to land
|
||||||
|
* instead of guessing with a fixed delay.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -22,12 +47,43 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
fallback()
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
// Track Coil's load state so the fallback doubles as a placeholder
|
||||||
|
// (loading) and an error state (404 / unreachable) — not just a
|
||||||
|
// null-URL guard, which left present-but-failing URLs blank.
|
||||||
|
var state by remember(resolved) {
|
||||||
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
|
}
|
||||||
|
// Empty counts as loading: it's the pre-request state, so treating it
|
||||||
|
// as settled would let a screen overlay lower before Coil even starts.
|
||||||
|
val loading = state is AsyncImagePainter.State.Empty ||
|
||||||
|
state is AsyncImagePainter.State.Loading
|
||||||
|
val tracker = LocalArtSettleTracker.current
|
||||||
|
DisposableEffect(tracker, loading) {
|
||||||
|
if (loading) tracker?.begin()
|
||||||
|
// Balanced by construction: the effect re-runs when `loading` flips
|
||||||
|
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||||
|
// the composition mid-load (scrolled away).
|
||||||
|
onDispose { if (loading) tracker?.end() }
|
||||||
|
}
|
||||||
|
val fallbackAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||||
|
animationSpec = tween(FALLBACK_FADE_MS),
|
||||||
|
label = "art-fallback",
|
||||||
|
)
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
|
if (fallbackAlpha > 0f) {
|
||||||
|
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val APK_FILENAME = "minstrel-update.apk"
|
private const val APK_FILENAME = "minstrel-update.apk"
|
||||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the server-bundled APK and hands it to Android's package
|
* Downloads the server-bundled APK and installs it over ourselves.
|
||||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
|
||||||
* side that the Flutter MethodChannel delegated to.
|
|
||||||
*
|
*
|
||||||
* The download goes through the shared [OkHttpClient] so it inherits
|
* The download goes through the shared [OkHttpClient] so it inherits
|
||||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||||
* cache dir, exposed to the system installer via the app's
|
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||||
* FileProvider content:// URI.
|
* [android.content.pm.PackageInstaller] session.
|
||||||
*
|
*
|
||||||
* On Android O+ the user must have granted "install unknown apps"
|
* On Android O+ the user must have granted "install unknown apps"
|
||||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
||||||
* opens the relevant settings screen.
|
* opens the relevant settings screen. That grant is still required with
|
||||||
|
* the session API — silent *updates* don't imply silent *permission*.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ApkInstaller @Inject constructor(
|
class ApkInstaller @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val session: SelfUpdateSession,
|
||||||
) {
|
) {
|
||||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
|||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
context.packageManager.canRequestPackageInstalls()
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
/**
|
||||||
fun launchInstall(apk: File) {
|
* Install [apk] over ourselves, suspending until the platform decides.
|
||||||
val uri: Uri = FileProvider.getUriForFile(
|
*
|
||||||
context,
|
* Note for callers: on a successful silent install this never returns —
|
||||||
"${context.packageName}.fileprovider",
|
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||||
apk,
|
*/
|
||||||
)
|
suspend fun install(apk: File): InstallOutcome = session.run(apk)
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, APK_MIME)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||||
fun requestInstallPermission() {
|
fun requestInstallPermission() {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal verdict from the platform on a self-update install (#2438).
|
||||||
|
*
|
||||||
|
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
|
||||||
|
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
|
||||||
|
* reports back, so "declined" and "failed" stop looking identical.
|
||||||
|
*/
|
||||||
|
sealed interface InstallOutcome {
|
||||||
|
/**
|
||||||
|
* The platform completed the install.
|
||||||
|
*
|
||||||
|
* Rarely observed on a self-update: our process is replaced the moment the
|
||||||
|
* new APK lands, so the coroutine awaiting this usually dies before it
|
||||||
|
* resumes. Modelled anyway — silently relying on being killed would make
|
||||||
|
* the success path invisible to anyone reading this.
|
||||||
|
*/
|
||||||
|
data object Installed : InstallOutcome
|
||||||
|
|
||||||
|
/** The user declined the platform's confirm dialog. Not an error. */
|
||||||
|
data object Cancelled : InstallOutcome
|
||||||
|
|
||||||
|
/** The platform refused. [reason] is its own message, where it gave one. */
|
||||||
|
data class Failed(val reason: String?) : InstallOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||||
|
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||||
|
* Settings About card.
|
||||||
|
*
|
||||||
|
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
|
||||||
|
* state because the install half was fire-and-forget and took no time from our
|
||||||
|
* side; now that we await the platform's verdict, collapsing them would leave
|
||||||
|
* the UI claiming "Downloading…" through an install that can sit on a confirm
|
||||||
|
* dialog indefinitely.
|
||||||
|
*/
|
||||||
|
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||||
|
|
||||||
|
/** True while an install is underway and a second tap should do nothing. */
|
||||||
|
fun InstallStage.isBusy(): Boolean =
|
||||||
|
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||||
|
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||||
|
* be a lie with a red tint.
|
||||||
|
*/
|
||||||
|
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||||
|
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||||
|
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||||
|
*
|
||||||
|
* Lives beside the outcome rather than in either UI package because two
|
||||||
|
* separate screens surface the same verdicts and must not drift — the same
|
||||||
|
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||||
|
* outside the UI layer.
|
||||||
|
*/
|
||||||
|
fun InstallOutcome.message(): String? = when (this) {
|
||||||
|
InstallOutcome.Installed -> null
|
||||||
|
InstallOutcome.Cancelled -> "Update cancelled."
|
||||||
|
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
|
||||||
|
?: "Couldn't install update."
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
private const val STAGED_APK_NAME = "minstrel-update"
|
||||||
|
|
||||||
|
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
|
||||||
|
private const val WRITE_FROM_START = 0L
|
||||||
|
|
||||||
|
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
|
||||||
|
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
|
||||||
|
*
|
||||||
|
* Split from [ApkInstaller] because the two halves are different work — one
|
||||||
|
* speaks HTTP, the other speaks to the package manager — and the session half
|
||||||
|
* carries a receiver, a PendingIntent and version-gated params that would
|
||||||
|
* crowd the downloader out of its own file.
|
||||||
|
*
|
||||||
|
* ## Why a session, rather than the ACTION_VIEW intent this replaced
|
||||||
|
*
|
||||||
|
* Two reasons, and the second is the one that matters to users.
|
||||||
|
*
|
||||||
|
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
|
||||||
|
* URI and hoped. It could not report an outcome, so a failed install and a
|
||||||
|
* user who declined looked identical — see [InstallOutcome].
|
||||||
|
*
|
||||||
|
* More importantly, a session is where the platform lets a self-updater say it
|
||||||
|
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
|
||||||
|
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
|
||||||
|
* manifest permission, is the sanctioned way to update with **no dialog at
|
||||||
|
* all**. The platform grants that when all of: the installer opts in (here),
|
||||||
|
* the installed app targets API 29+ (we're on 36), the installer holds the
|
||||||
|
* permission (we do), and the target is the installer itself or something it
|
||||||
|
* first installed (we are updating ourselves). All four hold.
|
||||||
|
*
|
||||||
|
* ## What is deliberately absent
|
||||||
|
*
|
||||||
|
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
|
||||||
|
* a self-updater and it is not: ownership can only be claimed on **initial**
|
||||||
|
* installation — setting it on an update is documented as a no-op — and it also
|
||||||
|
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
|
||||||
|
* stores claiming the apps they install, not for an app updating itself.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SelfUpdateSession @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Stage [apk] and hand it to the platform, suspending until a terminal
|
||||||
|
* verdict arrives.
|
||||||
|
*
|
||||||
|
* Never returns on the happy path when the install is silent: the platform
|
||||||
|
* replaces this process the moment the new APK lands, so the coroutine dies
|
||||||
|
* rather than resuming. Callers must treat that as success, not a hang.
|
||||||
|
*/
|
||||||
|
suspend fun run(apk: File): InstallOutcome {
|
||||||
|
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
|
||||||
|
return staged.fold(
|
||||||
|
onSuccess = { sessionId -> awaitCommit(sessionId) },
|
||||||
|
onFailure = { InstallOutcome.Failed(it.message) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a session, stream the APK in, return the session id. */
|
||||||
|
private fun stage(apk: File): Int {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val sessionId = installer.createSession(newParams())
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
|
||||||
|
apk.inputStream().use { source -> source.copyTo(sink) }
|
||||||
|
// fsync before the session closes: the platform validates the
|
||||||
|
// staged bytes at commit, and buffered tail bytes read as a
|
||||||
|
// truncated APK.
|
||||||
|
session.fsync(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
|
||||||
|
// runs on assembleRelease, and NewApi is easier for it to reason about when
|
||||||
|
// the guarded call has a named receiver instead of an implicit one.
|
||||||
|
private fun newParams(): PackageInstaller.SessionParams {
|
||||||
|
val params = PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||||
|
)
|
||||||
|
params.setAppPackageName(context.packageName)
|
||||||
|
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// The whole point of this class. Pre-S there is no such API, so the
|
||||||
|
// confirm dialog is unavoidable there — degrade, don't fail.
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit the session and wait for the platform to report back.
|
||||||
|
*
|
||||||
|
* A pending-user-action status is *not* terminal — the platform is asking us
|
||||||
|
* to show its dialog, and the real verdict arrives in a second broadcast
|
||||||
|
* once the user decides. So the receiver stays registered across it.
|
||||||
|
*/
|
||||||
|
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(unused: Context, intent: Intent) {
|
||||||
|
val status = intent.getIntExtra(
|
||||||
|
PackageInstaller.EXTRA_STATUS,
|
||||||
|
PackageInstaller.STATUS_FAILURE,
|
||||||
|
)
|
||||||
|
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||||
|
confirmWithUser(intent)
|
||||||
|
} else {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
receiver,
|
||||||
|
IntentFilter(RESULT_ACTION),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
// Stop listening, but deliberately do NOT abandon the session.
|
||||||
|
// Cancellation here means our caller's scope died — the user
|
||||||
|
// navigated away, or the VM cleared — and by this point the
|
||||||
|
// session is already committed. The user asked for this install;
|
||||||
|
// killing it because nobody is watching the banner any more
|
||||||
|
// would be the wrong reading of their intent.
|
||||||
|
runCatching { context.unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
|
||||||
|
}.onFailure { error ->
|
||||||
|
// Resuming normally means invokeOnCancellation never fires, so
|
||||||
|
// clean up the staged session here or it sits until it expires.
|
||||||
|
runCatching { context.unregisterReceiver(receiver) }
|
||||||
|
runCatching { installer.abandonSession(sessionId) }
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resume(InstallOutcome.Failed(error.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resultSender(sessionId: Int): IntentSender {
|
||||||
|
// Scoped to our own package so the broadcast can't be answered elsewhere.
|
||||||
|
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// The platform writes its status extras into this intent, so it has
|
||||||
|
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
|
||||||
|
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||||
|
}
|
||||||
|
// Session id as the request code keeps concurrent sessions from
|
||||||
|
// colliding on FLAG_UPDATE_CURRENT.
|
||||||
|
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the platform's own confirm dialog, which arrives as an extra.
|
||||||
|
*
|
||||||
|
* The system-app check is not ceremony. Below API 34 a dynamically
|
||||||
|
* registered receiver cannot declare itself unexported, so another app on
|
||||||
|
* the device can broadcast [RESULT_ACTION] at us — and calling
|
||||||
|
* `startActivity` on an attacker-supplied extra would hand it whatever we
|
||||||
|
* can reach. The genuine confirm activity belongs to the platform
|
||||||
|
* installer, so demanding a system component costs the real path nothing.
|
||||||
|
*/
|
||||||
|
private fun confirmWithUser(result: Intent) {
|
||||||
|
val pending = IntentCompat.getParcelableExtra(
|
||||||
|
result,
|
||||||
|
Intent.EXTRA_INTENT,
|
||||||
|
Intent::class.java,
|
||||||
|
) ?: return
|
||||||
|
if (isPlatformActivity(pending)) {
|
||||||
|
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPlatformActivity(intent: Intent): Boolean {
|
||||||
|
val flags = intent.resolveActivityInfo(context.packageManager, 0)
|
||||||
|
?.applicationInfo
|
||||||
|
?.flags
|
||||||
|
?: 0
|
||||||
|
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||||
|
return (flags and systemFlags) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||||
|
else -> InstallOutcome.Failed(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell-level soft banner that nudges an available update. Renders
|
* Shell-level soft banner that nudges an available update. Renders
|
||||||
@@ -79,7 +81,7 @@ private fun BannerBody(
|
|||||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||||
if (stage == InstallStage.DOWNLOADING) {
|
if (stage.isBusy()) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
// Downloading and installing are separate words because they're now
|
||||||
|
// separate waits — the install half suspends on the platform, which
|
||||||
|
// may be sitting on a confirm dialog.
|
||||||
|
Text(
|
||||||
|
when (stage) {
|
||||||
|
InstallStage.DOWNLOADING -> "Downloading…"
|
||||||
|
InstallStage.INSTALLING -> "Installing…"
|
||||||
|
else -> "Install"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user