Compare commits

...

225 Commits

Author SHA1 Message Date
bvandeusen 7b7bd0c3e8 Merge PR #95: You-might-like liked-entity fallback (#790)
test-go / test (push) Successful in 30s
release / Build signed APK (tag releases only) (push) Successful in 3m55s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-11 23:33:25 -04:00
bvandeusen 26c368c35e fix(recommendation): use type conversion for fallback rows (staticcheck S1016)
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m35s
golangci-lint v2 (CI-only; local is v1) flagged the field-by-field struct
literals — the fallback and you-might-like row types are identical, so convert
directly instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:26:35 -04:00
bvandeusen 36786defd1 feat(recommendation): fall back to liked entities when You-might-like is thin (#790)
test-go / test (push) Failing after 14s
test-go / integration (push) Has been cancelled
The taste roll-up surfaces top-similar albums/artists, which for a heavy
listener are mostly ones they already play — so the read-time dedup (vs Most
Played + Rediscover + Last Played) can strip the section down to a single tile
(reported on the artists row). The code was sound; the section was just starved.

Adds a read-time fallback: when a You-might-like row comes up short after dedup,
top it up from the user's LIKED artists/albums — a far larger pool than the
12-entity similarity roll-up, so the same exclusions still leave plenty. Reuses
the existing Rediscover-fallback queries (no new SQL), applies the same
exclusions (already-shown + Rediscover + Most/Last Played) so it never
duplicates a tile or suggests an actively-played entity, and is best-effort
(a query error leaves the section as-is). Takes effect immediately — no rebuild.

A cold-start user with no likes gets nothing from the fallback, so the
new-user-empty behaviour is preserved (test still passes).

Test: 20 liked artists, none played → Rediscover fills 10, You-might-like
fallback fills the other 10, disjoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:24:21 -04:00
bvandeusen 8019537b02 Merge PR #94: "You might like" web client row (#790)
test-web / test (push) Successful in 35s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 1m56s
2026-06-11 23:01:38 -04:00
bvandeusen 3f240bc777 feat(web): "You might like" Home row (#790 web client slice)
test-web / test (push) Successful in 32s
The web UI rendered a fixed set of Home sections and had no code for the
server's you_might_like_albums / you_might_like_artists (shipped in
v2026.06.11), so the row was absent in the web client. Adds it, mirroring
the Rediscover block, positioned directly under the system-playlists row.

- types.ts HomePayload: two new slices (server always emits them; web ships
  in lockstep with the server).
- +page.svelte: a "You might like" section (albums + artists scrollers) as the
  first section under the playlists row, with a "still learning your taste"
  empty state for the cold-start/gated case. Reuses existing AlbumCard /
  ArtistCard / HorizontalScrollRow.
- home.test.ts / page.test.ts: mock payloads gain the two fields.

Completes the You-might-like row across all three clients (server already
emits it; Android in v2026.06.11; web here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:59:58 -04:00
bvandeusen e41d603c12 Merge PR #93: move "You might like" under system-playlists row
android / Build + lint + test (push) Successful in 4m24s
release / Build signed APK (tag releases only) (push) Successful in 4m29s
release / Build + push container image (push) Successful in 14s
2026-06-11 22:55:52 -04:00
bvandeusen 1cc9553d1f fix(android): move "You might like" directly under the system-playlists row
android / Build + lint + test (push) Successful in 3m37s
Operator expected the row immediately beneath the system-generated playlists
section, not below Rediscover. Reorders the section call (presentation only —
no logic/state change) and updates the doc comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:50:02 -04:00
bvandeusen a62f07bd3a Merge PR #92: "You might like" Android client row (#790)
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
2026-06-11 22:36:22 -04:00
bvandeusen c2f9bbaff4 fix(android): suppress detekt TooManyFunctions on HomeRepository
android / Build + lint + test (push) Successful in 3m32s
The two new you-might-like observe accessors pushed HomeRepository from 11 to
13 functions, tripping detekt's per-class default. It's accessor density (one
observe method per Home row) on a thin pass-through repository, not complexity —
suppressed with a one-line rationale per the project convention. ktlint already
passed; this was the only detekt finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:19:13 -04:00
bvandeusen abc225e12a feat(android): "You might like" Home rows (#790 client slice)
android / Build + lint + test (push) Failing after 1m17s
Surface the server's you_might_like_albums / you_might_like_artists sections
(daily-built, cold-start gated, taste-aware) on the Home screen, mirroring the
Rediscover block.

- HomeIndexWire: two new slices, defaulted to emptyList() so decode is safe
  against older servers that don't emit them (Class-B discipline).
- HomeRepository: two section constants + observeYouMightLikeAlbums/Artists
  (reusing the existing album/artist hydration helpers) + refreshIndex now
  replaces both sections and pre-warms their artists. No Room schema change —
  cached_home_index stores the section string verbatim.
- HomeScreen: HomeSections gains the two fields (+ isAllEmpty); the ViewModel
  combine is split (core 5 → +2 you-might-like → +playlists) to stay within the
  coroutines 5-arity limit; a YouMightLikeBlock + youMightLikeSection render an
  albums-then-artists block below Rediscover, with a "still learning your taste"
  empty state for the cold-start/gated case.

Server side already shipped in v2026.06.11; this makes it visible on device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:16:32 -04:00
bvandeusen 3c646c6974 Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s
2026-06-11 21:41:17 -04:00
bvandeusen aff346c731 feat(taste): phase 2a — apply the taste profile via a TasteMatch scoring term (#796)
test-go / test (push) Successful in 39s
test-go / integration (push) Successful in 4m34s
The profile built in phase 1 now changes what gets surfaced. Adds a TasteMatch
term to the weighted-shuffle score so candidates are re-ranked by their fit to
the user's learned taste (positive draws toward it; negative reflects passive
avoidance; 0 at cold start).

- recommendation/score.go: ScoringInputs.TasteMatchScore ([-1,+1]) +
  ScoringWeights.TasteWeight + the term in Score.
- recommendation/taste.go: LoadTasteProfile reads the taste_profile_* tables;
  TasteProfile.Match blends the candidate's artist weight (0.7) and avg genre-tag
  weight (0.3), each tanh-squashed by a fixed scale so one outlier artist can't
  compress the rest. Unknown artist/tags and empty profiles → 0 (neutral).
- candidates.go: both candidate loaders set TasteMatchScore per candidate, so
  every Score caller (system playlists incl. You-might-like, radio) becomes
  taste-aware automatically.
- weights: systemMixWeights.TasteWeight = 1.5 (daily mixes are the primary
  taste surface); config.RecommendationConfig gains taste_weight (default 1.0,
  lighter — radio is seed-directed) wired into the radio handler.
- tests: pure (Match curve incl. saturation/clamp/empty-neutral, Score term
  add+subtract) + DB round-trip (seed taste rows → Match positive). All green
  vs real Postgres; existing playlist/radio tests unaffected (empty profile →
  zero taste effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:29:42 -04:00
bvandeusen 13b3fca949 fix(taste): drop unused now param + tighten test (golangci-lint revive)
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m25s
golangci-lint v2 (CI-only; local is v1) flagged two unused-parameter issues:
- BuildTasteProfile's `now` was genuinely dead — decay/windowing are computed
  DB-side via now(), so no Go-side timestamp is threaded. Removed it (a
  phase-3 context model that needs a pinned reference time would re-add it);
  updated the scheduler call site.
- the degenerate-params engagement test ignored t; reworked it to assert the
  result stays in [-1,1], which also strengthens the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:58:43 -04:00
bvandeusen 6e0d0e5723 feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
test-go / test (push) Failing after 25s
test-go / integration (push) Has been cancelled
Build a persistent, decaying model of each user's taste, recomputed daily,
that later phases consume across every recommendation surface. Phase 1 only
BUILDS the object — no behaviour change to what's surfaced yet.

Core mechanic — graded engagement (replaces binary was_skipped for learning;
was_skipped stays for History): a play's completion ratio maps to a signal in
[-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1).
Time-decayed (half-life ~75d) so recent behaviour dominates and the profile
tracks drift.

Per operator constraints:
- No explicit dislike button — negatives come only from passive behaviour
  (early skips). Nothing recorded to regret or opt out of.
- Negatives are track-scoped; artist/tag weight is the decayed SUM of their
  tracks' engagement, so one skip nets out against many good plays (a
  DB test asserts a liked artist stays positive despite an early-skipped
  track). A floor clamp bounds how negative any single entity can get.

- migration 0035: taste_profile_artists / taste_profile_tags (signed weight,
  indexed by (user, weight DESC)).
- internal/taste: engagement.go (pure curve + decay) + profile.go
  (accumulate plays + like bonuses, floor damping, size caps, atomic-replace).
- scheduler: rebuildUserDaily recomputes the profile before the playlist
  build (so phase 2 can read it), best-effort — a taste failure never blocks
  playlist building. Wired into the daily job + startup catch-up only (not
  manual/lazy rebuilds).
- tests: pure (engagement curve, decay, ranking, floor, genre split) +
  DB-backed (positive/negative weights, aggregation-protects-artist, like
  bonus, atomic replace). All green vs real Postgres.

Config knobs live in taste.DefaultConfig() for now; wiring them into the
server RecommendationConfig is a later follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:55:14 -04:00
bvandeusen 752906b054 fix(playlists): pin candidate order before jitter to make daily builds deterministic
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m33s
scoreAndSortCandidates drew per-candidate jitter by slice position, but
the candidate query (LoadRadioCandidatesV2) has ORDER BY random() arms and
no stable outer ordering, so DB row order varies call-to-call. When the
recency spread between candidates is smaller than the ±jitter (small or
recency-clustered libraries), two same-day rebuilds assigned jitter to
different tracks and reordered near-ties — so the build was not actually
deterministic-within-a-day as documented.

Pre-existing latent flake in TestBuildSystemPlaylists_DailyNonceDeterminism
(passed in isolation / by luck in CI; deterministically reproduced when the
system-build tests run in sequence). Confirmed independent of the
You-might-like change by neutralizing buildYouMightLike — the flake
persisted.

Fix: sort the candidate slice by track id before assigning jitter, so the
jitter for a track is a function of (track, day) alone, independent of DB
return order. Verified: full playlists package green 4/4 and the build-test
sequence green 5/5 (was 0/4 before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:56:13 -04:00
bvandeusen fdd14ef04c feat(server): "You might like" album/artist Home rows (#790)
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s
Surface in-library albums/artists the listener doesn't actively spin but
is predicted to enjoy, derived from the same similarity + like-weighted
candidate engine that powers For-You — rolled up from track scores to
album/artist granularity. Built in the daily 3am BuildSystemPlaylists
pass, atomic-replaced alongside the system playlists, and read back by
/api/home (+ /api/home/index).

Cold-start gate: skips generation entirely below 20 distinct unskipped
tracks AND 5 distinct artists, so a thin profile ships empty rows rather
than near-random tiles.

- migration 0034: you_might_like_albums / you_might_like_artists (id+rank,
  CASCADE, per-user rank index).
- playlists/you_might_like.go: cold-start gate + similarity roll-up
  (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the
  same userIDHash jitter as For-You) + atomic-replace persist in the tx.
- recommendation/home.go: two new HomePayload sections with read-time
  cross-section dedup vs Most Played / Rediscover / Last Played, trimmed
  to 10 each.
- api: you_might_like_albums / you_might_like_artists on /api/home and
  /api/home/index, reusing albumRefFrom / artistRefFromCovered.
- tests: pure roll-up/aggregation/cap unit tests + DB-backed gate,
  sufficiency, and atomic-replace tests (all green vs real Postgres).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:33:46 -04:00
bvandeusen 9a57dc4bec Merge pull request 'Offline/playback recording robustness (contract-audit follow-ups)' (#90) from dev into main
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 4m25s
release / Build signed APK (tag releases only) (push) Successful in 4m20s
release / Build + push container image (push) Successful in 1m53s
2026-06-11 14:19:27 -04:00
bvandeusen 4ecb1680bf fix(android): drop second loop jump in supersededLikeToggleIds (detekt)
android / Build + lint + test (push) Successful in 3m51s
LoopWithTooManyJumpStatements — replace the two continues with a
filtered sequence + a single null guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:54 -04:00
bvandeusen d2a22e49e3 fix: harden offline/playback recording (contract-audit follow-ups)
test-go / test (push) Successful in 42s
android / Build + lint + test (push) Failing after 1m27s
test-go / integration (push) Successful in 4m42s
Audit of every Android↔server connection point (2026-06-11) cleared the
silent-contract class that caused the events `type` bug, but surfaced a
cluster of offline/playback-robustness defects. Fixes:

1. Playback double-count on background. PlayEventsReporter no longer
   enqueues a partial play_offline + leaves the live row open on every
   screen-lock. closeCurrent() now routes by whether the server has an
   open row: close-by-id (durable PLAY_ENDED on failure) when it does,
   offline only when no row exists, and a no-op while a play_started is
   in flight (the server auto-closes that orphan). onStop only durably
   closes a *paused* play — a still-playing one is left to the live path
   under the foreground service. Adds the PLAY_ENDED mutation kind.

2. Replayer poison rows. MutationReplayer now classifies each replay as
   SENT / DROP / RETRY: permanent 4xx (and corrupt payloads) are dropped
   instead of retried forever; 408/429/5xx/transport still retry.

3. Offline-play / close-by-id idempotency (server). RecordOfflinePlay
   dedups on (user, track, started_at); RecordPlayEnded skips a second
   skip_events insert when re-closing an already-ended row. Makes the
   at-least-once replay safe against lost-response duplicates.

4. Like-toggle collapse. Replayer drops like-toggles superseded by a
   later toggle for the same entity, so partial-failure + differential
   retry can't invert the final like state.

5. Connectivity-return trigger. MutationReplayer + SyncController now
   also drain/sync when NetworkStatusController recovers to Healthy, so
   an offline→online transition mid-session doesn't wait for a cold
   start. SyncController.syncSafe gains a single-in-flight mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:05:35 -04:00
bvandeusen f167ddfbfb Merge pull request 'fix: restore native Android play-event recording (History was empty)' (#89) from dev into main
test-go / test (push) Successful in 38s
test-go / integration (push) Successful in 4m29s
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 3m59s
release / Build + push container image (push) Successful in 14s
2026-06-11 09:08:20 -04:00
bvandeusen 653ed95f14 fix(android): always serialize event type so plays aren't dropped
android / Build + lint + test (push) Successful in 5m21s
The native client's /api/events requests omitted the `type`
discriminator entirely. The app's Json is configured with
encodeDefaults=false (AppModule), so a `type` left at its data-class
default ("play_started" etc.) is never written to the wire. The server
multiplexes on `type` and returns 400 "unknown event type" for an
empty one, which PlayEventsReporter's catch swallows — and the
play_started path has no offline fallback, so the play is lost with no
trace.

Net effect: EVERY native Android play event (started/ended/skipped/
offline) has 400'd since this code was written. Listening History only
ever populated from the Flutter/web clients; as usage moved to the
native app, History went sparse. Confirmed live in the server access
log: POST /api/events -> 400 on every play, while reads 200.

Force the discriminator onto the wire with
@EncodeDefault(Mode.ALWAYS) on each request type's `type` field.
Surgical (vs flipping encodeDefaults globally), and idiomatic for a
constant-valued discriminator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:01:13 -04:00
bvandeusen b2c6f6f0e9 fix(server): apply skip rule on auto-close instead of force-hiding orphans
test-go / test (push) Successful in 35s
test-go / integration (push) Successful in 4m34s
autoClosePriorOpen hardcoded was_skipped=true for every orphaned
play_event (a play_started whose play_ended never arrived, e.g. the
client backgrounded mid-track). That hid fully-listened tracks from
History — a play that sat open past its own length was capped to the
track duration (ratio ~1) yet still flagged skipped. Observed live:
History showed 3 plays for a day of listening because most rows were
auto-closed orphans marked skipped.

Now the auto-close applies the same skip rule as RecordPlayEnded to the
duration-capped elapsed estimate: ratio >= threshold OR elapsed >= the
duration floor -> a real play that lands in History; a genuine
quick-abandon still classifies as a skip. Still writes no skip_events
row, so the ambiguous auto-close never feeds the skip-ratio /
recommendation signal.

This is the server half. The client-side root cause (backgrounded
track transitions never closed, orphaning the rows in the first place)
is tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:15:22 -04:00
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen dabca3ad24 fix(android): stop single SOAP failure from dropping Sonos to local
android / Build + lint + test (push) Successful in 3m44s
When the phone was locked, a transport command (lock-screen/Bluetooth/
watch media button, or queue-resync play) issued during a WiFi power-save
stall would hit the 2s connect timeout and throw. handleSoapFailure then
cancelled the poll loop and fired onDrop on that single failure -- showing
"Disconnected from <Sonos>" and reverting to local playback, even though
the renderer was perfectly reachable. This bypassed the poll loop's
deliberate 30-consecutive-failure tolerance (DROP_THRESHOLD, bumped from 3
precisely for screen-off WiFi sleep / Doze).

Make the 1 Hz poll loop the sole drop arbiter:
- Transport SOAP commands retry transient IO failures (retryTransientIo:
  3 attempts, 400ms backoff) so a brief WiFi stall lands the command once
  WiFi wakes instead of abandoning it. A SoapFaultException (renderer
  answered, rejected the action) is not retried -- the device is alive.
- handleTransportFailure no longer cancels the poll loop or fires onDrop;
  it logs and nudges 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 via DROP_THRESHOLD.

Extract retryTransientIo as an internal top-level fn + unit test covering
first-success, retry-then-succeed, exhaust-and-rethrow, and no-retry-on-
SoapFault. Refresh now-stale drop-heuristic comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:14 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 8f29cc7414 feat: poll in-flight requests on native + refresh after submit (#369)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m37s
Native RequestsViewModel gains poll-while-approved (silent reloads, paused
when nothing in-flight) for parity with the web auto-poll, and the SSE
collector now reloads silently instead of flashing the loading spinner.
Web discover submit invalidates qk.myRequests() so a new request appears
on /requests immediately.
2026-06-06 23:18:44 -04:00
bvandeusen c8b21aa76e fix(android): grid items() resolution on artist detail (drop bad alias)
android / Build + lint + test (push) Successful in 3m35s
2026-06-06 22:56:58 -04:00
bvandeusen 483804fc9e fix(android): extract ArtistSuccessBody to satisfy detekt LongMethod
android / Build + lint + test (push) Failing after 2m56s
2026-06-06 22:48:45 -04:00
bvandeusen 12a8cfccb5 feat(android): similar-artists strip + top-tracks on artist detail
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 40s
android / Build + lint + test (push) Failing after 1m16s
test-go / integration (push) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:39:54 -04:00
bvandeusen e358a92cf4 feat(web): similar-artists strip + top-tracks panel on artist detail 2026-06-06 22:34:46 -04:00
bvandeusen 63b25e65ad feat(server): similar-artists + per-user artist top-tracks endpoints
GET /api/artists/{id}/similar — in-library artists ranked by similarity
score (deduped across sources), ArtistRef list with cover + album count.
GET /api/artists/{id}/top-tracks — current user's most-played tracks for
the artist (skips excluded, quarantine filtered).
2026-06-06 22:30:41 -04:00
bvandeusen a766b7193f test(server): drop removed scheduler arg from New() callers
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
2026-06-06 22:00:21 -04:00
bvandeusen e95138d412 style(server): gofmt watcher_test map alignment
test-go / test (push) Failing after 10s
test-web / test (push) Successful in 33s
test-go / integration (push) Failing after 4m27s
2026-06-06 21:54:14 -04:00
bvandeusen 7426f6c718 feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:20 -04:00
bvandeusen e994aae613 feat(server): retire scan scheduler for watcher + safety-net walk
Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main;
remove the configurable scan scheduler (scheduler.go, scan_schedule table via
migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api
plumbing). Manual scan + scan status are unchanged.
2026-06-06 21:50:08 -04:00
bvandeusen c39a9ca18f feat(server): targeted ScanFiles + fsnotify library watcher
Add Scanner.ScanFiles (watcher-driven targeted scan returning changed album
IDs) and a recursive fsnotify Watcher that debounces filesystem events and
enriches just the affected albums inline. Pure classifyEvent/drainPending
seams unit-tested; ScanFiles covered in the scanner integration test.
2026-06-06 21:43:27 -04:00
bvandeusen 06e155abb6 fix(android): rename IdleRevertDecision.kt to satisfy detekt MatchingDeclarationName
android / Build + lint + test (push) Successful in 4m11s
2026-06-06 17:36:07 -04:00
bvandeusen 6b11208e5a perf(android): fail-fast connect timeout for UPnP transport SOAP
android / Build + lint + test (push) Failing after 1m8s
2026-06-06 17:30:28 -04:00
bvandeusen 41da012494 fix(android): failed UPnP play reverts to phone and resumes locally 2026-06-06 17:29:58 -04:00
bvandeusen 8a3104443c feat(android): record play-intent on remote play/pause 2026-06-06 17:29:36 -04:00
bvandeusen 5f5ab69da6 feat(android): track UPnP play-intent surviving SOAP errors 2026-06-06 17:29:19 -04:00
bvandeusen 5386ae870f feat(android): revert UPnP output to phone after 5-min idle 2026-06-06 17:28:32 -04:00
bvandeusen d7b011e52f feat(android): pure idle-revert decision for UPnP output 2026-06-06 17:27:52 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 13s
2026-06-05 13:27:36 -04:00
bvandeusen e185b36138 fix(android): arbitrate op-failure probe off the reducer thread
android / Build + lint + test (push) Successful in 3m33s
Awaiting probeOnce() inline in the OpFailure branch blocked the single-consumer
reducer for the /healthz timeout, delaying a concurrent self-proving success
from snapping back to Healthy. Launch the probe instead so recovery stays fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:47:46 -04:00
bvandeusen 31d8c30dfe feat(android): grey offline-unavailable playlists, lead with cached pools
android / Build + lint + test (push) Successful in 3m28s
Home + Playlists list derive offline from NetworkStatusController (Offline or
ServerDown, not the raw device link), fixing the consistency gap. PlaylistRef
gains unavailableOffline (refreshable || !fullyCached); PlaylistCard dims the
whole tile when greyed but stays tappable. buildPlaylistsRow offline: pools
lead, real playlists partitioned available-first, placeholders dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:40:30 -04:00
bvandeusen 69ccd7b25d feat(android): per-playlist fullyCached via cache-index join
android / Build + lint + test (push) Successful in 3m27s
CachedPlaylistDao.observeCachedCounts LEFT-JOINs cached_playlist_tracks ×
audio_cache_index per playlist; PlaylistsRepository.observeAll combines it in
and stamps PlaylistRef.fullyCached (trackCount>0 && cached>=trackCount). Merge
extracted to a pure mergePlaylistsWithCache for Android-free unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:29:59 -04:00
bvandeusen 8b77c6be97 feat(android): 4-state connection banner with unstable + back-online flash
android / Build + lint + test (push) Successful in 3m27s
Banner VM collects NetworkStatusController.state directly (drops the redundant
WhileSubscribed re-wrap). Adds a mild 'Reconnecting…' treatment for the
non-gating Unstable state and a transient 'Back online' confirmation on
down→Healthy recovery (try/finally guards against a stuck flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:23:55 -04:00
bvandeusen c33ef18a50 fix(android): extract OfflineGatedDataSource gate to satisfy detekt ThrowsCount
android / Build + lint + test (push) Successful in 3m24s
The added IOException rethrow pushed open() to 3 throws (max 2). Move the
two gating throws into a private gateOnHealth() helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:19:29 -04:00
bvandeusen 4c9450c117 feat(android): feed API/playback/pull-refresh signals into NetworkStatusController
android / Build + lint + test (push) Failing after 1m10s
OkHttp ReachabilityReportingInterceptor (Lazy to break the Hilt cycle) runs
first in the chain and reports only PLACEHOLDER_HOST (Minstrel-bound) 2xx/IO
outcomes so external artwork fetches don't read as server reachability.
OfflineGatedDataSource reports stream open success/failure; PlaybackErrorReporter
arbitrates on track failures; PullToRefreshScaffold re-probes on every pull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:14:50 -04:00
bvandeusen b467cb7532 refactor(android): unify offline detection into NetworkStatusController
android / Build + lint + test (push) Successful in 3m25s
Absorbs VersionCheckController (/healthz poll + version parse) and
ServerHealthController (tri-state derive) into one signal-driven authority.
Adds the non-gating Unstable state across all ServerHealth branch sites
(OfflineGatedDataSource, SearchRepository, TrackRow, banner). Repoints
MinstrelApplication, MainActivity, PlayerFactory, VersionTooOldViewModel.
Drops the now-unused nowMs params the detekt UnusedParameter rule flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:06:22 -04:00
bvandeusen c78bbb7ba5 feat(android): pure ReachabilityMachine + 4-state ServerHealth enum
android / Build + lint + test (push) Failing after 1m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:35:22 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 5c0db429b3 fix(android): don't flip offline on WAN-validation flicker — trust /healthz
android / Build + lint + test (push) Successful in 3m38s
A self-hosted Minstrel server is usually on the LAN, but ConnectivityObserver
gated 'online' on NET_CAPABILITY_VALIDATED — which tracks whether Android
reached its own WAN internet-validation probe, not whether Minstrel is
reachable. A transient WAN/DNS blip (or Android's periodic re-validation)
momentarily drops VALIDATED while the LAN server stays reachable. That flipped
ServerHealth -> Offline with NO debounce (only the /healthz path got hysteresis),
and OfflineGatedDataSource fast-failed the in-flight stream read with
OfflineException -> ExoPlayer SOURCE error -> the load_failed 'Source error'
event. On-device: 'app said server offline while it wasn't', one track failed,
then recovered when VALIDATED returned.

- ConnectivityObserver: require INTERNET only, not VALIDATED. The /healthz poll
  (VersionCheckController, with its own failure hysteresis) is the authority on
  whether Minstrel is reachable; the device-link signal only answers 'is there a
  network at all' (airplane mode).
- ServerHealthController: add a WARN-tier transition log. The signal had zero
  instrumentation, which is why this was hard to diagnose from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:57:57 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen 58810a860b fix(android): notification art uses album cover, not embedded stream tags
android / Build + lint + test (push) Successful in 3m38s
The MediaController notification / lock-screen background pulled artwork
from the stream's embedded ID3/FLAC tags (artworkData) because the
MediaItem never set artworkUri — a different source than the in-app
album cover (/api/albums/{id}/cover). For tracks whose embedded tag art
differs from the server album cover, the two surfaces disagreed.

- PlayerController.toMediaItem: set artworkUri to TrackRef.coverUrl.
  MediaMetadata.populate() overwrites artworkUri+artworkData as a pair,
  so the MediaItem URI clears the embedded bytes ExoPlayer extracts from
  the stream — the album cover now wins on both surfaces.
- PlayerFactory.buildBitmapLoader: OkHttp-backed CacheBitmapLoader so the
  authed placeholder cover URL resolves (the default DefaultHttpDataSource
  loader can't rewrite placeholder.invalid or attach the auth cookie).
- MinstrelPlayerService: attach it via MediaSession.setBitmapLoader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:24:20 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 9a31955fa4 perf(android): gate audio prefetch on isPlaying
android / Build + lint + test (push) Successful in 3m41s
Cold-start playback on a fresh install was taking ~25 s before any
audio played. Logcat showed AudioPrefetcher was kicking off N
concurrent CacheWriter jobs the instant setQueue updated uiState --
each prefetch a full upcoming-track download over the same OkHttp
client as the current-track DataSource. Five-way bandwidth split
plus parallel Coil cover fetches starved the current track until
its full file body had streamed through (~12 MB at ~1 MB/s under
contention).

Now reconcile() observes uiState.isPlaying and starts upcoming-track
prefetches only when the current track is actually playing.
Cancellation of out-of-window jobs always runs so a queue switch or
skip still frees the pipe immediately, even while paused. Cold start
should drop from ~25 s -> 5-7 s on the user's network: just the
single-stream throughput plus the one-time TLS/DNS tax.

Refactored the inline reconcile body into computeTargets /
cancelOutOfWindowLocked / startInWindowLocked helpers to keep
ReturnCount under the detekt cap.
2026-06-04 17:29:19 -04:00
bvandeusen e7d7cb2471 fix(android): incremental Sonos queue sync for playNext + radio-append
android / Build + lint + test (push) Successful in 3m55s
The previous fix re-loaded Sonos's full queue on every uiState.queue
identity change -- correct for playlist-switch (full replacement) but
disruptive for in-queue mutations: playNext and radio-append would
restart the currently-playing track on Sonos because removeAllTracks
+ AddURIToQueue x N + SetAVTransportURI re-anchors the transport.

Now the resync runs a longest-common-prefix / common-suffix diff first.
When the current Sonos track lies in the preserved prefix, applies the
minimum-incremental SOAP operations -- RemoveTrackRangeFromQueue on the
removed middle, AddURIToQueue at the same insertion point -- so Sonos
keeps playing the current track and the new entries land in place
without interrupting playback. Falls back to the full removeAllTracks
reload when the current track is in the removed slice (playlist
switch).

Adds AVTransportClient.removeTrackRangeFromQueue (Sonos-specific,
UpdateID=0 skips the queue-version check).

Cases now covered:
  - Playlist switch -> full reload (current track replaced, prefix=0)
  - playNext insert  -> 1 AddURIToQueue at the right slot
  - Radio-append     -> RemoveTrackRangeFromQueue for old tail + N
                        AddURIToQueue for new tracks at the end
2026-06-04 17:07:26 -04:00
bvandeusen 8017934334 fix(android): re-prime Sonos queue when user plays a different playlist
android / Build + lint + test (push) Successful in 3m56s
Before: tapping a different playlist while Sonos was the active route
updated the player view but Sonos kept the old queue and played those
tracks (or whatever was last there). PlayerController.setQueue replaced
the local ExoPlayer queue and called play(), which forwarded SOAP Play
to Sonos -- but Sonos's native queue (loaded once at route selection
via removeAllTracks + AddURIToQueue + SetAVTransportURI) was never
touched on subsequent setQueue calls.

Now: MinstrelForwardingPlayer.setMediaItems (all 3 overloads) clears
holder.active + sets target synchronously so the immediately-following
play() drops via isLoadingUpnp(). OutputPickerController observes
uiState.queue identity changes; when target or active is non-null and
the queue key shifted, it re-runs loadQueueOnSonos under the existing
selectUpnpMutex and restores active when done. Sonos resync failures
drop cleanly to local (selectedUpnpRouteIdInternal nulled).

Doesn't touch addMediaItem / radio-append paths -- those leave Sonos's
queue stale and need a separate AddURIToQueue extension hook; out of
scope for this fix.
2026-06-04 17:00:24 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen faa0c7024b fix(android): hysteresis on /healthz reachable signal — 3 consecutive failures
android / Build + lint + test (push) Successful in 3m55s
The single-failure flip-to-false produced two false-positive permanent
banner cases:

1. Startup race — AuthStore.baseUrl loads from Room asynchronously, so the
   first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
   ("http://localhost:8080") before the real server URL has hydrated. One
   failure was enough to lock the banner on for the next 5 minutes.

2. Deployments whose reverse proxy routes only /api/* to the Go server —
   /healthz never reaches the handler, so /healthz polls fail forever even
   though every real /api/* call succeeds. User sees "Server unreachable"
   permanently and OfflineGatedDataSource starts throwing OfflineException
   on every audio cache miss, silently breaking playback of uncached
   tracks.

Now we require 3 consecutive failures (~15 min at the 5-min poll cadence)
before flipping reachable=false, and any single success resets the
counter. Adds Timber.w/i at the flip transitions so operator logcat can
diagnose genuine outages.
2026-06-04 13:40:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 1e17eeda72 feat(android): #618 Phase 5 — confirm offline writes via shell snackbar
android / Build + lint + test (push) Successful in 3m58s
MutationQueue now emits "Saved — will sync when online" on a SharedFlow
whenever a user-driven enqueue lands (like toggle, playlist append,
request create/cancel, quarantine flag/unflag). Background enqueues
(play-offline events, playback-error reports) do not emit — those fire
from non-foreground paths where a snackbar would be either dropped
(no shell mounted) or jarring (lock-screen toggle).

ShellScaffold subscribes via OfflineWriteHintViewModel and routes the
hint through its existing snackbar host. Replaces the prior silent-
queue UX where a tap on a like / playlist add looked successful but
the user couldn't tell whether the server had been hit or the call
was deferred for replay.
2026-06-04 12:40:59 -04:00
bvandeusen 1daea79f64 feat(android): #618 Phase 4 — row-level offline-unavailable affordance
android / Build + lint + test (push) Has been cancelled
TrackRow now consumes the LocalServerHealth CompositionLocal (provided
once at MainActivity from ServerHealthController.state). When the server
is Offline or ServerDown and the track id isn't in LocalCachedTrackIds,
the row dims to 0.4 alpha and a tap fires a Toast instead of attempting
playback. Replaces the silent "tap-and-fail-to-OfflineException" UX with
explicit at-a-glance signaling of which rows in a long list will work.

Trailing slot (kebab / like / playlist-add) stays interactive so write
affordances can route through MutationQueue — Phase 5 gates those at
the action level.
2026-06-04 12:37:42 -04:00
bvandeusen 48de720514 feat(android): #618 Phase 2 — search falls back to cached entities when offline
android / Build + lint + test (push) Has been cancelled
When ServerHealthController reports Offline or ServerDown, SearchRepository
runs Room LIKE queries against cached_artists / cached_albums / cached_tracks
instead of hitting /api/search. The screen draws a one-line hint above the
results so the user can tell server matches from on-device-only matches.

Adds searchByName / searchByTitle DAO methods; LOCAL_SEARCH_LIMIT=20 matches
the server's default page size.
2026-06-04 12:35:01 -04:00
bvandeusen fced6b681e feat(android): cache-only audio path when ServerHealth != Healthy
android / Build + lint + test (push) Successful in 4m58s
Phase 3 of #618. Wraps the OkHttpDataSource upstream of CacheDataSource with OfflineGatedDataSource. CacheDataSource only consults the upstream factory on a cache miss, so playback of cached audio is unaffected. Offline tap on a non-cached track now throws OfflineException immediately (subclass of IOException for ExoPlayer's PlaybackException to wrap) instead of waiting on a multi-second OkHttp timeout. AudioPrefetcher keeps its own ungated upstream -- writes fail silently when offline, no user-visible impact.
2026-06-04 11:27:11 -04:00
bvandeusen 80a6be25aa feat(android): ServerHealth tri-state composite + banner distinguishes offline vs server-down
android / Build + lint + test (push) Has been cancelled
Phase 1 of #618. VersionCheckController gains reachable: StateFlow<Boolean> from the same /healthz poll (no double polling). ServerHealthController combines connectivity.online + versionCheck.reachable into ServerHealth { Healthy, Offline, ServerDown }. ConnectionErrorBanner now branches on the tri-state, distinguishing 'no Wi-Fi' from 'server unreachable.' Phases 2-5 (local search, cache-only audio source, row-level not-cached affordance, write-affordance gray-out) ship separately as independent slices.
2026-06-04 11:25:22 -04:00
bvandeusen 4d0a0b8e09 fix(android): throttle UPnP extend with 50ms delay per successful AddURIToQueue
android / Build + lint + test (push) Successful in 4m10s
Closes Scribe #611. The 2026-06-04 logcat showed 33 consecutive AddURIToQueue failures clustered at ~10ms intervals once the burst hit offset 39 -- characteristic of Sonos's burst-add rate-limit. 50ms between successful adds adds ~5s to the 100-track background extension but eliminates the burst rejection. Next reproduction with the SOAP fault detail logging (audit commit c5b326c6) will confirm the fault code if any tracks still fail.
2026-06-04 11:03:55 -04:00
bvandeusen 6184c62721 feat(android): MediaSession picks up UPnP state via direct listener invocation
android / Build + lint + test (push) Has been cancelled
Closes Scribe #606. Two pieces: MediaMetadata gets durationMs (lock-screen scrubber gets a known total even when wrapped ExoPlayer is paused under UPnP); MinstrelForwardingPlayer keeps an externalListeners registry that mirrors super.addListener so we can directly invoke onIsPlayingChanged / onPlaybackStateChanged / onMediaItemTransition when remoteState mutates. Fires from pollOnce + play()/pause() onSuccess. dedup via lastNotified guards so we don't spam events at 1Hz when nothing changed.
2026-06-04 11:02:51 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen 28300e19fd perf(ci): turn off Postgres durability in integration tests
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
TRUNCATE-everything ResetDB before every test forces a commit fsync; the CI DB is rebuilt each run so durability buys nothing. ALTER SYSTEM via docker exec (the services: block can't override the postgres command line). Non-fatal so a perms surprise degrades to slow, never red.

Per the playbook the operator shared from another project (~17x speedup observed there). Measure before/after in the next two CI runs.
2026-06-04 08:31:06 -04:00
bvandeusen 024493f2a7 refactor(server): unify stream URL builders + MIME tables + cover-path helper
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit.

- streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant.

- audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs.

- coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
2026-06-04 08:29:51 -04:00
bvandeusen edd198cdf5 fix(server): collage drawScaled uses center-crop instead of stretch
test-go / test (push) Successful in 27s
test-go / integration (push) Has been cancelled
2026-06-04 08:22:59 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 8cd2383a42 test(server): seed track for cast-token tests + assert file-ext in URL
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 9m34s
2026-06-04 07:44:33 -04:00
bvandeusen 27bd38e005 feat(server): stream URL gets file extension so Sonos can probe duration
test-go / test (push) Successful in 37s
test-go / integration (push) Failing after 10m34s
2026-06-04 07:29:28 -04:00
bvandeusen aa23a72693 fix(android): effectiveDuration uses desiredIdx so duration tracks the displayed title
android / Build + lint + test (push) Successful in 4m2s
2026-06-04 07:17:17 -04:00
bvandeusen 4021938046 fix(android): polling tick no longer force-syncs controller -- avoids Sonos seek-to-0
android / Build + lint + test (push) Successful in 3m26s
2026-06-04 07:06:07 -04:00
bvandeusen 7486bc2444 fix(android): split tickPositionPoll into helpers for detekt complexity
android / Build + lint + test (push) Has been cancelled
2026-06-04 07:04:01 -04:00
bvandeusen ee8a1fdc93 fix(android): event-driven pending-transport clear (Sonos ack OR 5s safety)
android / Build + lint + test (push) Failing after 1m12s
2026-06-04 07:00:22 -04:00
bvandeusen 8e578d2068 fix(android): 2s transport-ack lockout so Prev/Next isn't undone by stale poll
android / Build + lint + test (push) Successful in 3m25s
2026-06-04 06:55:39 -04:00
bvandeusen cacb280832 fix(android): polling tick track update is forward-only so user Next isn't undone
android / Build + lint + test (push) Successful in 3m27s
2026-06-04 06:50:35 -04:00
bvandeusen 36054506c2 fix(android): polling tick owns track-change updates when delegate.seekTo silent
android / Build + lint + test (push) Successful in 3m26s
2026-06-04 06:46:38 -04:00
bvandeusen 5db90844cb fix(android): DIDL-Lite includes Rincon namespace + cdudn desc for Sonos
android / Build + lint + test (push) Successful in 3m39s
2026-06-04 06:34:00 -04:00
bvandeusen d5437d517e fix(android): single break in extend loop for detekt LoopWithTooManyJumpStatements
android / Build + lint + test (push) Successful in 3m52s
2026-06-04 01:07:57 -04:00
bvandeusen 3085d6f409 fix(android): DIDL-Lite restricted=true / id=-1 so Sonos accepts metadata
android / Build + lint + test (push) Failing after 1m33s
2026-06-04 00:59:31 -04:00
bvandeusen c5b326c620 fix(android): UPnP extend captures SOAP fault, aborts after 3 failures
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:56:48 -04:00
bvandeusen 389c896d65 fix(android): force poll on ON_RESUME + cap interpolation drift at 5s
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:55:10 -04:00
bvandeusen 41230b5afb fix(android): single jump per polling loop for detekt LoopWithTooManyJumpStatements
android / Build + lint + test (push) Successful in 4m0s
2026-06-04 00:42:35 -04:00
bvandeusen c245b1ef0b fix(android): hold UI patch until cursor sync lands; reanchor on track flip
android / Build + lint + test (push) Failing after 1m21s
2026-06-04 00:37:34 -04:00
bvandeusen 2425a305eb fix(android): interpolate UPnP position between polls + suppress post-seek race
android / Build + lint + test (push) Successful in 4m5s
2026-06-04 00:16:57 -04:00
bvandeusen 88b161193d fix(android): TrackRef.durationSec is final fallback so duration is never 0
android / Build + lint + test (push) Successful in 5m54s
2026-06-04 00:03:48 -04:00
bvandeusen 9628ed1749 fix(android): duration falls back to local while Sonos hasn't reported one
android / Build + lint + test (push) Has been cancelled
2026-06-04 00:00:45 -04:00
bvandeusen 85926f4ec0 fix(android): keep service alive when UPnP is playing -- override playWhenReady
android / Build + lint + test (push) Successful in 3m53s
2026-06-03 23:51:54 -04:00
bvandeusen 47b0894ad6 test(android): update RemotePlayerState threshold expectation to 30
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:50:37 -04:00
bvandeusen e62fac3a0e fix(android): onEvents reads UPnP state so resume keeps duration
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:48:52 -04:00
bvandeusen eae5dcad23 fix(android): correct ErrorCopy import path in PlaylistPlayback
android / Build + lint + test (push) Failing after 6m2s
2026-06-03 23:42:18 -04:00
bvandeusen 3576e241c0 fix(android): split playPlaylistShuffled to satisfy detekt ReturnCount
android / Build + lint + test (push) Failing after 3m42s
2026-06-03 23:37:30 -04:00
bvandeusen 8f89279fa4 fix(android): UPnP survives screen-off + cursor catches up to Sonos
android / Build + lint + test (push) Has been cancelled
2026-06-03 23:36:43 -04:00
bvandeusen b1a66f18bd feat(android): shuffle on system playlist tile play -- Home + Playlists list
android / Build + lint + test (push) Failing after 1m30s
2026-06-03 23:29:42 -04:00
bvandeusen 6a7958c921 fix(android): debounce non-PLAYING poll to suppress UPnP track-change flicker
android / Build + lint + test (push) Successful in 4m13s
2026-06-03 23:19:29 -04:00
bvandeusen 33285b53c6 fix(android): refresh uiState.isPlaying from remoteState during UPnP
android / Build + lint + test (push) Successful in 4m1s
2026-06-03 23:08:32 -04:00
bvandeusen 87ad7f4dc2 feat(android): lazy queue activation + loading spinner during UPnP load
android / Build + lint + test (push) Successful in 3m55s
Part A: split loadQueueOnSonos into an initial phase (tracks[0..currentIndex]
only, then SetAV+Seek+Play) plus a background extendQueueOnSonos coroutine
that appends the remainder after activation. Reduces the UPnP activation
block from ~17s (100 tracks serial) to ~200ms (1 track at currentIndex=0).
Background extension cancels cleanly when activeUpnpHolder.active changes.

Part B: add PlayerUiState.isUpnpLoading (target set, active null). Projected
inline in onEvents so it stays consistent with the rest of the snapshot, plus
a separate combine(target, active) collector that updates uiState between
player-event fires. NowPlayingScreen.TransportRow and MiniPlayer.MiniRow
replace the play/pause icon with a CircularProgressIndicator while loading
and disable the button tap to prevent premature commands to the Sonos queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:56:06 -04:00
bvandeusen 9c0013f4b6 fix(android): defer holder.active until SOAP wired -- drop transport during load
android / Build + lint + test (push) Successful in 4m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:46:57 -04:00
bvandeusen 6129536153 diag(android): log ForwardingPlayer transport overrides on entry
android / Build + lint + test (push) Successful in 3m50s
2026-06-03 22:32:33 -04:00
bvandeusen e6c3c959fa fix(android): handleZeroDurationIfNeeded ReturnCount within cap
android / Build + lint + test (push) Successful in 3m45s
2026-06-03 22:25:53 -04:00
bvandeusen e011b04e04 fix(android): remove pollLoop cursor sync -- races with queue load and SOAP
android / Build + lint + test (push) Has been cancelled
2026-06-03 22:24:34 -04:00
bvandeusen e2866795ef fix(android): skip zero-duration auto-advance during UPnP playback
android / Build + lint + test (push) Failing after 1m26s
2026-06-03 22:18:11 -04:00
bvandeusen e20d7b1438 fix(android): correct SOAPACTION assertions in next/previous tests
android / Build + lint + test (push) Successful in 3m58s
2026-06-03 22:06:54 -04:00
bvandeusen 2a098a78fe feat(android): Sonos queue mode -- ClearQueue + AddURIToQueue + x-rincon-queue
android / Build + lint + test (push) Failing after 9m9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:55:03 -04:00
bvandeusen ece37e9a92 fix(android): remove pollLoop natural-advance -- races with selectUpnp and skip
android / Build + lint + test (push) Successful in 4m1s
Polling alone cannot distinguish Sonos auto-advancing via SetNextAVTransportURI
from URI changes we made ourselves via syncCurrentItemToRemote.  This produced
two races: (1) activation race -- first poll returns stale URI from prior
session, second returns new URI, false-positive fires and double-advances the
cursor; (2) user-skip race -- skip's syncCurrentItemToRemote changes the URI,
next poll sees the change and fires again.  Remove the detection block and
previousTrackUri capture from pollOnce entirely.  pollLoop is now a pure
state-tracker (position + transport state) plus the one-shot initial pre-queue
gate.  GENA event subscriptions to AVTransport LastChange are the correct fix;
deferred to its own slice (see parity-map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:42:01 -04:00
bvandeusen 8a1203c4a1 fix(android): revert x-rincon-mp3radio Sonos URI -- plain https for music tracks
android / Build + lint + test (push) Successful in 4m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:31:31 -04:00
bvandeusen 487d1bd430 fix(android): SOAP/UPnP parsers enable processNamespaces -- correct name extraction
android / Build + lint + test (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:58:59 -04:00
bvandeusen 5c99341b34 fix(android): test assertions use JUnit Jupiter for lazy-message Supplier<String>
android / Build + lint + test (push) Failing after 3m31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:51:28 -04:00
bvandeusen 8fe3308afd fix(android): detekt -- parseHhMmSs ReturnCount/MagicNumber + readUntilEndTag jumps
android / Build + lint + test (push) Failing after 3m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:45:00 -04:00
bvandeusen 96594ba52b fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag
android / Build + lint + test (push) Failing after 1m29s
2026-06-03 20:40:52 -04:00
bvandeusen 2c61d7a333 fix(android): Sonos UDN comparison strips _MR/_MS suffix -- coordinator routing
android / Build + lint + test (push) Failing after 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:29:08 -04:00
bvandeusen 8652b86f40 fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:20:25 -04:00
bvandeusen 75132a2afe diag(android): log raw SOAP response body for first 3 UPnP polls
android / Build + lint + test (push) Failing after 1m22s
Add optional onRawResponse callback to SoapClient; loggingSoapClient
factory emits the first 6 GetPositionInfo/GetTransportInfo bodies
(3 poll cycles) at WARN so release logs capture them. Wire into
transportFor so every AVTransportClient for a new UPnP session logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:12:15 -04:00
bvandeusen 673f98487f fix(android): UPnP local pause via delegate -- no SOAP race
android / Build + lint + test (push) Failing after 1m30s
Move local ExoPlayer pause from OutputPickerController.selectUpnp
into MinstrelForwardingPlayer.onActiveChanged (handler.post { delegate.pause() }).
This guarantees the pause hits ExoPlayer before the holder is live, eliminating
the async race that caused SOAP fault 701 on Sonos when pause() was dispatched
via playerController after holder.active was already set.
Also adds per-poll Timber.w before initialPreQueueDone for diagnostics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:53:41 -04:00
bvandeusen c556388a6b fix(android): UPnP activation order -- pause local before holder.set; sync+preQueue sequential; diagnostic Timber.w
android / Build + lint + test (push) Failing after 1m28s
- OutputPickerController.selectUpnp: pause ExoPlayer BEFORE setting
  activeUpnpHolder so ForwardingPlayer.pause() routes to ExoPlayer,
  not SOAP; remove now-redundant playerController.pause() from inside
  runCatching; bump activation Timber.i -> Timber.w for release logcat
- MinstrelForwardingPlayer: remove Player.Listener onMediaItemTransition
  that raced with seekToNext/Prev override's syncCurrentItemToRemote;
  seekToNext/Prev now launch sync -> preQueueNext sequentially in one
  coroutine; remove early preQueueNext from onActiveChanged (raced with
  selectUpnp SOAP); move initial pre-queue to pollLoop, fires once
  trackUri lands confirming Sonos accepted SetAV+Play
- Extract pollOnce from pollLoop to stay within detekt LongMethod=60;
  natural-advance branch now calls preQueueNext explicitly (no listener)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:47:19 -04:00
bvandeusen edffdec2b2 fix(android): UPnP drop falls back to local; pollLoop detects natural advance
android / Build + lint + test (push) Failing after 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:07:20 -04:00
bvandeusen 1ab21d81ca feat(android): SetNextAVTransportURI pre-queue for gap-free UPnP advance
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:53:10 -04:00
bvandeusen 81794e2475 fix(android): UPnP volume cache keyed to active route id
android / Build + lint + test (push) Has been cancelled
2026-06-03 17:52:00 -04:00
bvandeusen 29309d9bfb ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:43:55 -04:00
bvandeusen 70b29567fb refactor(android): service holds Player not ExoPlayer for ForwardingPlayer wrap
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:36:35 -04:00
bvandeusen 2f4d67d3c8 feat(android): PlayerFactory wraps ExoPlayer in ForwardingPlayer + drop events
android / Build + lint + test (push) Failing after 1m35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:34:35 -04:00
bvandeusen b2bfe96559 ui(android): LazyColumn stable keys for in-place output picker row continuity
android / Build + lint + test (push) Failing after 1m21s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:33:01 -04:00
bvandeusen e9dd3e4d2a fix(android): OutputPicker -- local capture, selectUpnp mutex, shared dedup helper, suppress
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:31:40 -04:00
bvandeusen b29875fd30 feat(android): UPnP selection state, disconnect, BuiltIn-pinned alphabetical sort
android / Build + lint + test (push) Failing after 1m21s
OutputPickerController now owns selection state for the UPnP leg and
runs the disconnect flow when the user picks a system route while a
renderer is active.

- Inject OkHttpClient + RemotePlayerState so we can build a
  RenderingControlClient at selection time and capture the last-known
  remote position on disconnect.
- selectUpnp publishes ActiveUpnp(routeId, routeName, avTransport,
  rendering) to ActiveUpnpHolder, marks the route id in
  selectedUpnpRouteIdInternal, and honors Sonos topology by routing
  through coordinatorRouteFor before SOAP.
- selectSystem now does the disconnect: AVTransport.Stop -> clear the
  holder -> seek local ExoPlayer to the remembered position -> resume
  if the remote was playing.
- routesState combines 4 sources (system, UPnP, Sonos topology,
  upnp-selected id). Non-coordinator Sonos members are filtered out
  of the visible list. current resolves from the merged list when a
  UPnP route is selected; otherwise from the system snapshot.
- sortRoutes drops the current-first rule -- BuiltIn "Phone speaker"
  pins to the top, everything else lowercase-alphabetical. Selection
  state moves to the radio-button indicator in the picker row.
- RemotePlayerState gets @Singleton + @Inject constructor() so Hilt
  can provide the shared instance to both the picker and the
  forthcoming MinstrelForwardingPlayer.
2026-06-03 17:24:08 -04:00
bvandeusen 85cea8d559 fix(android): UPnP forwarding -- route mint failures through drop, no double drop
android / Build + lint + test (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:18:53 -04:00
bvandeusen ab6c3a1354 feat(android): MinstrelForwardingPlayer wraps ExoPlayer with UPnP branching
android / Build + lint + test (push) Failing after 1m25s
Task 7 of UPnP transport-parity slice. Introduces the central
ForwardingPlayer that branches between local ExoPlayer and the active
UPnP renderer:

- MinstrelForwardingPlayer wraps the delegate Player; play/pause/seek
  and the next/previous transport calls translate to AVTransport SOAP
  when an ActiveUpnp is set, otherwise forward to super. Position +
  isPlaying + duration + playbackState reads pull from
  RemotePlayerState while remote.
- 1Hz poll loop drives GetPositionInfo + GetTransportInfo, feeding
  RemotePlayerState; the rolling-3 failure heuristic fires onDrop on
  the looper for the factory to surface as a snackbar.
- StreamTokenProvider extracts the CastApi.create() Retrofit wiring
  into a Hilt singleton so the service-side player and the
  controller-side picker share one CastApi instance.
- OutputPickerController constructor swaps Retrofit for
  StreamTokenProvider + ActiveUpnpHolder (the holder is wired now for
  Task 8). selectUpnp now mints via streamTokens.mint(trackId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 17:12:11 -04:00
bvandeusen 3aee2276bc feat(android): RemotePlayerState container for UPnP-synthesized player state
android / Build + lint + test (push) Failing after 1m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:08:04 -04:00
bvandeusen 9a7d3b2d30 feat(android): ActiveUpnpHolder singleton for picker -> player handoff
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:06:33 -04:00
bvandeusen 9002cf5559 refactor(android): UPnP discovery cleanup -- bareUdn helper, Timber, suppress, kdoc
android / Build + lint + test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:05:31 -04:00
bvandeusen a5e4570f01 feat(android): UPnP in-place updates + Sonos topology aggregation
android / Build + lint + test (push) Failing after 1m14s
2026-06-03 16:59:31 -04:00
bvandeusen bfcb9c42a0 feat(android): Sonos ZoneGroupTopology client + parser
android / Build + lint + test (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:53:29 -04:00
bvandeusen 799d50024c test(android): RenderingControl lower clamp + GetVolume request body checks
android / Build + lint + test (push) Failing after 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:51:07 -04:00
bvandeusen 8c0c4c8600 feat(android): RenderingControl get/set volume
android / Build + lint + test (push) Failing after 1m33s
2026-06-03 16:48:40 -04:00
bvandeusen 3cdb416f94 fix(android): AVTransport test assertions escape DIDL; consolidate xmlEscape
android / Build + lint + test (push) Failing after 1m18s
DIDL assertion now checks for XML-escaped form (&lt;dc:title&gt;) since
SoapClient.buildEnvelope escapes all arg values. Lifts xmlEscape to a
top-level internal fun in SoapClient.kt, removing the duplicate private
copy from AVTransportClient. Fixes @Suppress rationale (not Compose).
Renames seek test to reflect colon-separated format; adds unknown-state
getTransportInfo test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:47:18 -04:00
bvandeusen d62a3b8134 feat(android): AVTransport pause/seek/nextURI/positionInfo/transportInfo
android / Build + lint + test (push) Failing after 1m35s
- Add pause(), seek(positionMs), setNextAVTransportURI(uri, mime, title)
- Add getPositionInfo() -> PositionInfo, getTransportInfo() -> TransportInfo
- Extract buildDidlLite() helper; add formatHhMmSs / parseHhMmSs helpers
- Add PositionInfo, TransportState, TransportInfo top-level types
- Add AVTransportClientTest covering all four new call shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:42:04 -04:00
bvandeusen 574bf29a7e fix(android): PlayerController transport methods dispatch to controller thread
android / Build + lint + test (push) Successful in 4m17s
After DIDL fix, Sonos accepted SetAVTransportURI + Play, but
playerController.pause() threw IllegalStateException 'method is
called from a wrong thread' because selectUpnp runs the whole
UPnP-selection flow on Dispatchers.Default. UI tap handlers were
fine - they're already on Main - but the cross-thread background
call from OutputPickerController.selectUpnp hit the MediaController's
application-thread guard.

Same fix as the cold-boot resume one earlier today (commit e69a5204
wrapped setQueue): pause / play / seekTo / skipToNext / skipToPrevious
now route through runOnControllerThread, which is a no-op when
already on the application looper and Handler.post otherwise.

Logcat from on-device confirmed Sonos plays after this fix lands -
SetAVTransportURI -> 200, Play -> 200, then the IllegalStateException
was the last failure path.
2026-06-03 16:00:53 -04:00
bvandeusen 24b7c92abd fix(server+android): DIDL-Lite metadata for Sonos UPnP (error 1023)
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m14s
test-go / integration (push) Failing after 9m12s
After the X-Forwarded-Proto fix Sonos now gets a clean https:// URL
but returns vendor error 1023 - empty CurrentURIMetaData. Sonos
requires DIDL-Lite metadata with at minimum <res protocolInfo>
carrying the audio MIME type so it can validate the source before
playback. The original spec said 'Sonos accepts empty DIDL; recoverable
if a device rejects' - that was wrong for Sonos.

Server (cast_token.go):
- Look up the track and return mime (from tracks.file_format) +
  title in the cast-token response. mimeForFormat covers the common
  formats - mp3, flac, m4a/aac, ogg, opus, wav - falling through to
  audio/mpeg for unknowns.
- Missing track returns 404 (apierror.NotFound) instead of letting the
  caller mint a token for nothing.

Client (CastApi.kt, AVTransportClient.kt, OutputPickerController.kt):
- StreamTokenResponse gains mime + title (defaulted so old contracts
  stay parseable).
- AVTransportClient.setAVTransportURIWithMetadata builds minimal Sonos-
  acceptable DIDL-Lite around the URL + MIME + title. xml-escaped.
- selectUpnp calls the new overload; Timber.i now logs the MIME so the
  next on-device test shows it.

Generic UPnP renderers tolerate the DIDL shape too - no downside to
sending it everywhere.
2026-06-03 15:54:56 -04:00
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen 7d15f57e86 fix(server): cast token URL honors X-Forwarded-Proto / Host
test-go / test (push) Successful in 36s
test-go / integration (push) Successful in 9m48s
On-device test against Sonos showed SetAVTransportURI returning UPnP
error 714 (IllegalMimeType). Logcat:

  POST /api/cast/stream-token -> 200 (token minted)
  SetAVTransportURI to http://minstrel.fabledsword.com/...
  <-- 500 from Sonos: SoapFaultException SOAP fault 714

The server is behind a TLS-terminating reverse proxy, so r.TLS is
nil and the URL builder emitted http://. Sonos does a HEAD probe to
detect the audio MIME type; against an http:// URL that 301s to
https://, the probe finds no audio body and bails with 714.

The Task 2 code-quality reviewer flagged this exact scenario at the
time. Closing it now: honor X-Forwarded-Proto + X-Forwarded-Host
before falling back to r.TLS + r.Host. Public URL the speaker
fetches now matches the scheme/host the client used to reach the
endpoint.
2026-06-03 15:17:34 -04:00
bvandeusen a9edc12523 fix(android): release-build Timber tree at WARN+ for operator diagnosis
android / Build + lint + test (push) Successful in 4m2s
Debug builds got DebugTree; release builds had no tree planted at
all, so Timber.w / Timber.e calls were dropped silently in
production. That's how the UPnP select diagnostic-prints went
invisible during on-device testing - the released APK had no Timber
output reaching logcat.

Plant a release-only Tree that emits at WARN and above via
android.util.Log.println with the canonical 'Minstrel' tag (or the
caller-supplied tag when present). Keeps DEBUG / INFO traffic out of
production logcat (the chatty stuff is the part we don't want
flooding the buffer) while letting operator-driven adb logcat
sessions still see real failures.
2026-06-03 15:01:03 -04:00
bvandeusen 96f12d6aac fix(android): UPnP select - loud logs on every code path
android / Build + lint + test (push) Has been cancelled
Two silent early returns in selectUpnp were swallowing the most
likely failure modes:
  - currentTrack null (nothing playing locally → can't cast a track)
  - transportFor() returns null (route disappeared or id mismatch)

On-device verification reported 'tap collapses the sheet but no
audio routes', with logcat empty - one of these was firing without
any signal.

Each early-return now Timber.w's why; the runCatching block adds
Timber.i breadcrumbs at every step (mint token, SetAVTransportURI,
Play, done) so the next failure shows exactly how far we got.
2026-06-03 14:59:57 -04:00
bvandeusen 036da9dea8 fix(android): UPnP - clean Sonos friendlyName for picker display
android / Build + lint + test (push) Successful in 4m28s
Sonos uses the friendlyName format
  'Room - Device Type - RINCON_<UDN>'

The picker was showing it verbatim, so the user saw rows like
  'Living Room - Sonos Play:1 Media Renderer - RINCON_5CAAFD79...'

Now strips on the first ' - ' for Sonos manufacturer matches, so the
chip shows just 'Living Room' / 'Kitchen' / etc. Subtitle (manufacturer
+ model) still renders below per the existing sheet design, so the
device-type info isn't lost.

Generic UPnP devices that append a '(192.168.x.x)' IP suffix get that
stripped too via an end-of-string-anchored regex. Empty / blank
friendlyName still falls back to 'Network speaker'.
2026-06-03 14:54:39 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
test-go / integration (push) Successful in 11m14s
release / Build signed APK (tag releases only) (push) Successful in 4m22s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen 7c11cdc4d1 fix(server): handleGetStream - auth check before DB lookup
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 9m40s
TestRoutesRegisteredInMount failed because handleGetStream did the
DB lookup (404 on missing track) BEFORE streamAuthOk (401 on
unauth). For an unauth request to a non-existent track, the test
saw 404 and concluded the route wasn't registered when actually it
was - the handler just bailed at the lookup before auth.

Reorder: extract trackID via chi.URLParam, run streamAuthOk on the
raw path id first (the HMAC token is signed over the same id
string so we don't need the resolved row yet), then do the DB
lookup. Test now sees 401 on the unauth probe as it expected.

Also closes a small info-leak: previously a 404/401 differential
let unauth callers probe which track IDs exist. Now both unknown
and known IDs return 401 for unauth requests.
2026-06-03 13:43:28 -04:00
bvandeusen c3614c6333 fix(server): errcheck violations from UPnP slice
test-go / test (push) Successful in 29s
test-go / integration (push) Has been cancelled
golangci-lint flagged three errcheck:
- stream_token.go: fmt.Fprintf(mac, ...) - hash.Hash never errors
  per documented contract, but errcheck wants explicit discard.
  Discard via _, _ assignment with a WHY comment.
- config_test.go: os.Unsetenv calls in tests - discard the error
  via _ assignment. Test cleanup paths.

Reviewers flagged the Fprintf one during Task 1 quality review but
golangci-lint runs in a separate CI step that wasn't exercised on
the per-task pushes (cancelled by subsequent push concurrency).
2026-06-03 13:41:54 -04:00
bvandeusen 9e67088fdb fix(server): TestRoutesRegisteredInMount - missing streamSecret arg
test-go / test (push) Failing after 13s
test-go / integration (push) Failing after 9m41s
go vet caught the test's Mount call missing the trailing []byte
streamSecret arg added by the UPnP slice's Task 2. The test passed nil
for *playlists.Scheduler but didn't pass anything for []byte, so the
arg count was one short.

Added nil for the streamSecret position - the test exercises route
registration only, not the cast-token endpoint, so the secret value
doesn't matter for what this test asserts.
2026-06-03 13:30:36 -04:00
bvandeusen 6da6cb5c5a fix(server): daily-rotate all deterministic mixes + diversity top-up fallback
test-go / test (push) Failing after 12s
test-go / integration (push) Failing after 6m50s
Operator feedback on the prior unification commit (7473e98d):

1. NewForYou should daily-rotate alongside Rediscover and FirstListens.
   The 'newest album first regardless of day' intent was the wrong
   call - operator wants visible day-over-day movement on every
   deterministic mix surface. Spec flipped to dailyRotate: true.

2. Diversity caps (<=2 per album / <=3 per artist) on every mix, not
   just the historically-diverse ones. The 2-per-album limit has
   helped a lot on the operator's library; extending it to NewForYou
   and FirstListens (previously album-coherent / no cap) surfaces
   more distinct albums per day. Spec flipped to diversify: true on
   all five.

3. Fallback when diversity caps strip the pool below the 100-track
   target: finishMix now calls topUpFromRaw, which appends non-capped
   tracks from the raw SQL pool (preserving original ranked order +
   skipping duplicates) until the target is hit or the pool runs out.
   On rich libraries the cap yields >= 100 and top-up never runs; on
   thin / album-heavy libraries we ship a partly-diversified 100
   instead of a strictly-diversified 40.

Net effect: every deterministic mix now rotates day-over-day, every
mix gets the same diversity treatment (with graceful degradation),
and the producer surface stays a single factory over a spec list.
2026-06-03 13:22:23 -04:00
bvandeusen 7473e98d91 fix(server): unify discovery-mix producers + daily-rotate the deterministic ones
test-go / test (push) Failing after 19s
test-go / integration (push) Has been cancelled
The five discovery-mix producers (Deep Cuts, Rediscover, New for you,
On this day, First listens) were near-identical boilerplate that
differed only in (a) which SQL query they ran and (b) whether to
diversity-cap the result. Folded into one produceDiscoveryMix(spec)
factory + a per-mix discoveryMixSpec slice. The registry composes the
factory over the spec list so adding a new mix is one struct literal
+ a SQL query, never a new func.

Also fixes the user-reported bug that several mixes 'show the same
content from yesterday'. Audit of the SQL queries:

  - Deep Cuts:   ORDER BY md5(t.id::text || $2::text)   → day-keyed
  - On this day: ORDER BY w.c DESC, md5(...)              → day-keyed
  - Rediscover:  ORDER BY tier, c DESC, id                → invariant
  - New for you: ORDER BY al.created_at DESC, disc, track → invariant
  - First listens: ORDER BY tier, al.id, disc, track      → invariant

The three invariant ones produced identical content day-over-day. The
unified spec carries a dailyRotate bool: when set, the producer
applies a daily-deterministic offset rotate-left of the candidate
pool BEFORE diversify+truncate. Rotation (not shuffle) preserves
contiguous-block ordering inside each day's slice — matters for First
listens which is album-coherent.

Set on Rediscover + First listens (where same-content-every-day is
clearly a bug). Left off New for you because 'newest album first
regardless of day' is the intended UX for that surface — daily
rotation there would feel wrong.

Daily rotation seed: rand.New(NewSource(int64(userIDHash(userID,
dateStr)))) — same primitive used by For-You's pickHeadAndTail
sampling so behavior is consistent across the system playlist family.

No test file referenced the deleted produceXxx functions directly,
only the registry, so this is a closed refactor.
2026-06-03 13:18:17 -04:00
bvandeusen 3df5e5cb3c fix(android): playlist like state + playlist cover URL
android / Build + lint + test (push) Successful in 3m48s
Two independent bugs surfaced together:

Bug 1: like button on tracks in playlist/album detail screens didn't
reflect actual liked state. LikesRepository.observeLikedTracks() does
a mapNotNull join against trackDao - a liked track whose row isn't in
the local cache yet (e.g. liked via web/notification, cache not
sync'd) gets DROPPED from the returned list. PlaylistDetailViewModel
+ AlbumDetailViewModel both used that as their like-set source, so
those rows showed as not-liked.

Adds LikesRepository.observeLikedTrackIds(): Flow<Set<String>> that
hits the DAO directly via observeLikedIdsOfType - no trackDao join,
no drops. The two ViewModels switch to it. LikedTab continues to use
observeLikedTracks because it needs the full TrackRef to render.

Bug 2: playlist cover art didn't render on the playlist detail
header. Server's derivePlaylistView returns CoverURL as the relative
path "/api/playlists/<id>/cover". PlaylistsRepository's two domain
mappers (CachedPlaylistEntity.toDomain + PlaylistDetailWire
.toPlaylistRef) stored it verbatim - Coil's AsyncImage can't fetch a
relative URL with no base, so the image silently failed.

Wraps the coverPath/coverUrl through resolveServerUrl so the
placeholder.invalid host triggers BaseUrlInterceptor's live-server
rewrite, same idiom every other cover surface (album / artist / track
/ playlist track rows) already uses.

System-playlist 24h refresh investigation pending - need to know how
you verified (server logs, DB state, or client-visible content)
before I can dig into the right layer.
2026-06-03 13:11:55 -04:00
bvandeusen 448c9f2e74 chore(android): UPnP picker - log selectUpnp failures + drop dead fetchJob
android / Build + lint + test (push) Successful in 3m54s
Code-quality review flagged two non-blockers on commit 03cdff54:

1. selectUpnp's runCatching swallowed SOAP / token-mint failures
   silently - OkHttp's logger doesn't see them since they happen in
   our own deserialize / parse code. Adds Timber.w on the failure
   path so operator's on-device Sonos verification can find the
   cause in logcat instead of staring at "nothing happened".

2. UpnpDiscoveryController's fetchJob field was assigned but never
   read or cancelled. appScope is process-lifetime so the launched
   coroutine dies with the process - no explicit cancellation is
   needed. Drop the field + the now-unused Job import.
2026-06-03 12:56:45 -04:00
bvandeusen 03cdff547d feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
UpnpDiscoveryController - Hilt singleton that owns the SSDP listener,
follows each discovered LOCATION URL to fetch + parse the device
description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>.

OutputPickerController now combines system routes with the UPnP
Flow into a unified RouteSnapshot. select() branches by protocol:
SYSTEM goes through MediaRouter as before; UPNP requests a signed
stream token via POST /api/cast/stream-token then calls
AVTransport.SetAVTransportURI + Play against the discovered device.
Local playback pauses on UPnP selection.

OutputPickerSheet gains a MulticastHintRow shown when no UPnP
devices appear after a 3s grace period - the 'your router may be
blocking multicast' footer hint per the spec.

Closes the UPnP slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
On-device verification pending: pair a Sonos / UPnP speaker, confirm
discovery + selection + playback + the multicast-blocked hint.
2026-06-03 12:52:15 -04:00
bvandeusen f8c93e013d feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s
SoapClient - minimal SOAP 1.1 envelope builder + POST via the shared
app OkHttpClient. Sets the SOAPACTION + Content-Type headers UPnP
expects, parses the action's Response element as a Map<String,
String>, raises SoapFaultException on a <s:Fault> response with the
UPnP errorCode + errorDescription extracted.

AVTransportClient - thin wrapper over SoapClient pinned to the
AVTransport:1 service. Three actions for v1: SetAVTransportURI /
Play / Stop. Pause + Seek deferred until we have hardware in the
loop to verify per-device quirks.

Three MockWebServer-driven unit tests cover the SOAPACTION header
shape, XML escaping of special chars in arg values, and the fault
response -> SoapFaultException path. kxml2 on the test classpath
(Task 4) makes XmlPullParserFactory resolve on the JVM.
2026-06-03 12:43:54 -04:00
bvandeusen 1f02813cc6 fix(android): UPnP - add kxml2 to test classpath so DeviceDescriptionTest runs
android / Build + lint + test (push) Successful in 4m7s
Android's XmlPullParserFactory is a Stub-throwing class in android.jar
on the JVM unit-test classpath; the probe pattern from dc5b8252 was
silently skipping the test suite, which gives false test-coverage
confidence. kxml2 is the same parser implementation Android uses
internally - service-provider lookup picks it up automatically once
on the test classpath.

The probe + Assumptions.assumeTrue skip removed; tests now run
unconditionally.

testImplementation(libs.kxml2) - 2.3.0, MIT-licensed, ~80KB. No
production code change.
2026-06-03 12:02:51 -04:00
bvandeusen dc5b8252bb feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled
SsdpDiscovery - UDP multicast listener on 239.255.255.250:1900.
Passive NOTIFY listen always-on once start() is called; explicit
M-SEARCH M-SEARCH on requestActiveScan() (called when picker sheet
opens). WifiManager.MulticastLock held only while running. Emits
each discovered LOCATION URL on a SharedFlow for downstream
description-fetching.

DeviceDescription - pull-parse the <device> XML returned from a
LOCATION URL, extracting friendlyName / manufacturer / modelName +
AVTransport + RenderingControl service control URLs. Filters out
devices without AVTransport (we can't control them).

Three unit tests cover a Sonos-shaped description, a non-renderer
device that should be dropped, and a minimal description with
missing optional fields.
2026-06-03 12:01:00 -04:00
bvandeusen 5f3905f2c7 feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
UpnpRoute - narrow domain model for a discovered UPnP / DLNA
renderer. Carries the AVTransport + RenderingControl control URLs
the SOAP client uses.

CastApi - Retrofit interface for the new POST /api/cast/stream-token
endpoint (UPnP slice 2/6). Returns {token, exp, url} for the
selection path.

OutputRoute.fromUpnpRoute - companion factory that tags the route
with Protocol.UPNP. Subtitle is 'Manufacturer Model' or falls back
to 'Network speaker' when description fields are blank.

CHANGE_WIFI_MULTICAST_STATE manifest permission - install-time on
all API levels, no runtime prompt. Required for SSDP multicast
discovery.

Discovery + SOAP + integration land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:51:11 -04:00
bvandeusen e774097fd8 feat(server): POST /api/cast/stream-token + secret bootstrap (UPnP slice 2/6)
test-go / test (push) Failing after 15s
test-go / integration (push) Failing after 5m54s
Adds the client-facing endpoint that issues a signed stream URL for
the current track. Authenticated via the standard session cookie.
Returns {token, exp, url} where url is a fully-formed stream URL
the client passes verbatim to a UPnP / Sonos device's
AVTransport.SetAVTransportURI call.

expSeconds clamped to [60, 86400]; default 21600 (6h) - long enough
to play through any typical track without re-minting mid-playback.

MINSTREL_STREAM_SECRET is loaded from env var with a per-machine
fallback persisted at <Storage.DataDir>/stream_secret (auto-generated
on first boot via 64 random bytes, base64-url-encoded, 0600). The
file-based fallback is operator-machine-scoped runtime state, not a
user-facing setting - chosen over a DB column to avoid a migration
and keep the secret out of cross-instance restores. Operator can
override at any time via the env var; default path requires zero
config.

Tests cover happy-path token issuance + URL formatting, bad-UUID
rejection, unauthenticated rejection, the expSeconds clamp at all
boundaries, secret env override, auto-gen + file persistence at 0600,
second-boot reuse of the persisted file, and rejection of a malformed
env value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:46:57 -04:00
bvandeusen 236637fcd3 feat(server): HMAC stream token auth path (UPnP slice 1/6)
test-go / test (push) Failing after 24s
test-go / integration (push) Failing after 9m54s
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over
trackID|exp) and modifies handleGetStream to accept either the
existing session cookie OR a valid signed token. Stream route
moved out of the authed group so the handler's own auth check
runs and the token bypass is reachable.

Enables Sonos / UPnP speakers to fetch the stream URL without
carrying the user's session cookie - they cannot. The token is
short-lived (max 24h per the design); expiry checked at request
time only, not per-byte, so long tracks play through.

streamSecret field on handlers is nil for now; Task 2 wires the
loader (env var with auto-generated fallback persisted in
app_preferences).

Adds auth.OptionalUser - the permissive sibling of RequireUser
that attaches the user to context when a valid cookie / bearer is
present but does NOT 401 on absence. The stream route is wrapped
with it so the handler can fall through to the token path when
no session is present.

newLibraryRouter (test fixture) gets a synthetic-user middleware
on the stream route so existing media_test tests keep passing
without seeding a real session row - production traffic uses
auth.OptionalUser, the test path uses auth.UserCtxKeyForTest().

Five tests cover round-trip, tampered token rejection, expiry,
wrong-track-ID, and wrong-secret rejection. CI verifies.
2026-06-03 11:34:02 -04:00
bvandeusen d7fe515940 fix(android): output picker CI - passive discovery + LongMethod
android / Build + lint + test (push) Successful in 6m26s
Two failures on the slice's final dev tip:

1. OutputPickerController referenced
   MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY which doesn't exist
   in androidx.mediarouter 1.7.0 - the spec hallucinated it.
   Passive discovery is the default behavior when addCallback is
   called with no flag argument. Use the 2-arg overload for the
   init block and downgradeDiscovery; keep CALLBACK_FLAG_REQUEST_DISCOVERY
   for upgradeDiscovery.

2. NowPlayingBody grew to 82 lines after the Task 5 output-picker
   wiring (state collection + permission launcher + LaunchedEffect
   + conditional Sheet). Extracted the BLUETOOTH_CONNECT permission
   plumbing into rememberBluetoothPermissionState, the Column layout
   into NowPlayingContent, and the scrubber+transport pair (which
   share the smoothed playback position) into PlaybackControlsBlock.
   NowPlayingBody is back to ~34 lines and the new helpers each sit
   well under detekt's 60-line LongMethod cap.
2026-06-03 10:49:32 -04:00
bvandeusen 8258b6c29f feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s
Bluetooth slice (5/5). Wires the OutputPickerViewModel + chip +
sheet into NowPlayingScreen.

- Chip renders between BottomActionsRow and ScrubberRow, hidden
  via shouldShowChip() when the only route is the built-in speaker
  (no useful picker with one option).
- Sheet appears on chip tap; selecting a route or dismissing flips
  the ViewModel state and downgrades MediaRouter discovery.
- BLUETOOTH_CONNECT permission requested via the modern
  ActivityResultContracts.RequestPermission() pattern on first
  sheet open. permissionDenied flag passed through to the sheet so
  the 'pair in Settings' hint renders when refused.

Closes the Bluetooth slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md.
On-device verification still pending: pair a Bluetooth speaker,
confirm chip + sheet + select + audio routes; verify wired plug
auto-update + permission-denial hint + long-name truncation.
2026-06-03 10:42:30 -04:00
bvandeusen d10113db54 fix(android): output picker - add Settings icon to permission hint
android / Build + lint + test (push) Failing after 3m13s
Spec's edge-case table calls for the BLUETOOTH_CONNECT permission
hint footer to render alongside a Lucide.Settings icon. Task 4
landed the hint text but not the icon. One-line spec fix on top
of commit a319e3f6.
2026-06-03 10:39:05 -04:00
bvandeusen a319e3f66d feat(android): output picker Compose UI - chip + sheet
android / Build + lint + test (push) Has been cancelled
Bluetooth slice (4/5). DeviceChip: Spotify-style current-route
indicator with icon + name + chevron, single-line ellipsis on long
names. iconFor() maps Kind to Lucide icons (Smartphone / Headphones
/ Bluetooth / Cast / Speaker).

OutputPickerSheet: Material 3 ModalBottomSheet. Header 'Output',
rows = icon + name + 2-line subtitle + selection state (CircleCheck
accent for selected, Circle outline otherwise). Tap selects +
dismisses. permissionDenied flag controls a footer hint row when
BLUETOOTH_CONNECT was refused.

NowPlayingScreen wiring lands in the final commit.
2026-06-03 10:35:48 -04:00
bvandeusen 692d9dab60 feat(android): OutputPickerViewModel - sheet lifecycle + selection
android / Build + lint + test (push) Has been cancelled
Bluetooth slice (3/5). HiltViewModel projecting the controller's
routesState Flow plus a sheetVisible MutableStateFlow that owns
the sheet's open/close state. onChipTapped + onSheetDismissed
forward to the controller's discovery toggle so active MediaRouter
discovery only runs while the sheet is visible (battery cost).

Compose UI + NowPlaying wiring land next.
2026-06-03 10:33:06 -04:00
bvandeusen 087486d253 feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s
Bluetooth slice (2/5). Hilt singleton over androidx.mediarouter.
Owns the callback lifecycle (passive at process start, upgrades to
active when the picker sheet opens, reverts on close) and exposes
the route state as a StateFlow<RouteSnapshot> the ViewModel
projects.

Routes are sorted current-first then by Kind (Bluetooth, Wired,
BuiltIn, Other) so the active output is always at the top of the
sheet.

ViewModel + Compose UI follow in next commits.
2026-06-03 10:30:23 -04:00
bvandeusen 0662c9d5cc feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled
Bluetooth slice (1/5). Adds the androidx.mediarouter 1.7.0 dep,
declares BLUETOOTH_CONNECT (needed on Android 12+ to enumerate
paired BT devices by name), and lays down the OutputRoute domain
model.

OutputRoute decouples the picker UI from MediaRouter.RouteInfo
(framework class, can't be constructed in JVM tests - same
constraint we hit with LikeMediaCallback). The Protocol enum
includes UPNP/CAST/SONOS placeholders so the next slice slots in
without a data-model rename - see
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md
for the deferred work.

Controller + ViewModel + Compose UI land in follow-up commits.
2026-06-03 10:27:02 -04:00
bvandeusen e69a5204db fix(android): PlayerController.setQueue dispatches to controller thread
android / Build + lint + test (push) Successful in 4m15s
Crash on cold boot: ResumeController.restore is suspend, lands on
Dispatchers.Default after awaitReady() unblocks (drift #562), and
calls PlayerController.setQueue which calls MediaController.setMediaItems
— MediaController enforces application-thread access and throws
IllegalStateException 'method is called from a wrong thread'.

Drift #562 added awaitReady() to fix the race where setQueue
early-returned on null controller and silently dropped the persisted
queue. That fix exposed the next bug down the stack: the threading
violation that was previously masked by the early-return.

setQueue now posts the MediaController calls to the controller's
applicationLooper if we're not already on it. UI callers (already
Main) run inline with no re-dispatch latency. ResumeController's
cold-boot path lands on the right thread.

Discovered on-device 2026-06-03 during like-button verification on
the Pixel 6 Pro emulator — crash log at PlayerController.kt:190.
2026-06-03 09:48:38 -04:00
bvandeusen 4be7e47584 chore(android): drop redundant !! on PlaylistRef.systemVariant
android / Build + lint + test (push) Successful in 3m49s
Line 264 already null-checks playlist.systemVariant in the if
condition. PlaylistRef is a data class with a val backing field,
so the smart cast narrows it to String inside the branch — the
!! on line 265 was a no-op the Kotlin compiler was warning about.
2026-06-03 09:38:24 -04:00
bvandeusen 551bbf83c2 feat(android): wire MediaSession like button + reactive state
android / Build + lint + test (push) Successful in 3m54s
MinstrelPlayerService now injects LikesRepository, attaches the new
LikeMediaCallback, sets an initial unfilled CommandButton via
setMediaButtonPreferences, and launches a service-scoped job that
rebuilds the preferences list when the current track or its
server-side liked state changes.

flatMapLatest on (currentMediaItem x observeIsLiked) means the icon
mirrors cross-device likes (web tap flips the notification heart
within EventsStream propagation) and never leaks Flows across track
transitions. Initial emission on subscription guarantees the icon is
correct on the first frame the controller renders.

onDestroy now cancels the service scope before releasing the session
so the like-state job can't touch a released MediaSession.

Closes the Media3 like-button work spec'd in
docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
On-device verification still pending: phone notification, lock
screen, Pixel Watch, Android Auto, offline replay, cross-device.
2026-06-03 09:28:37 -04:00
bvandeusen 43754d03c4 revert(android): drop LikeMediaCallback JVM tests + testOptions flag
android / Build + lint + test (push) Successful in 5m26s
The unit tests called Media3's SessionCommand(String, Bundle)
constructor, which checkNotNulls the Bundle. JVM unit tests have
no real Android — Bundle.EMPTY is a static field initialized via
the stub jar to null. isReturnDefaultValues=true escapes the
ExceptionInInitializerError but leaves Bundle.EMPTY as null, so
SessionCommand still NPEs on construction. The real fix is
Robolectric, which is disproportionate infrastructure for one
test file (pulls in JUnit 4 ceremony for a JUnit 5 project + a
heavy dep + first-run SDK download flake risk on this CI).

Verification gate for the like button is operator on-device check
per feedback_definition_of_done. The Task 2 wiring lands next,
then we verify the heart appears on the phone notification, lock
screen, and Pixel Watch end-to-end.
2026-06-03 09:19:37 -04:00
bvandeusen 7807e31b22 fix(android): testOptions isReturnDefaultValues = true
android / Build + lint + test (push) Failing after 2m55s
Unit tests touching Android framework statics (Bundle.EMPTY,
android.os.Bundle constructor in MediaItem/SessionCommand
construction) failed with NPE/ExceptionInInitializerError because
JVM unit tests run against android.jar's stub classes whose methods
throw "Method ... not mocked" by default. Enable
isReturnDefaultValues so stub methods return defaults — Bundle.EMPTY
ends up null and is fine because we just thread it through
SessionCommand without inspecting it.

Fixes LikeMediaCallbackTest's 5 failures on run #311. Lightweight —
no Robolectric, no androidTest. The first JVM-side test file in the
project to touch Android framework classes.
2026-06-03 09:11:59 -04:00
bvandeusen 9a7cfac7f8 fix(android): LikeMediaCallback ReturnCount — extract toggle helper
android / Build + lint + test (push) Failing after 2m51s
detekt: onCustomCommand had 3 returns (unsupported / no-mediaItem /
success), ReturnCount cap is 2. Pull the toggle path into a private
launchToggleForCurrent helper so onCustomCommand is a single
return (if/else picks the result code, one Future wrap) and the
helper has at most 2 returns.
2026-06-03 07:40:33 -04:00
bvandeusen d37ef56bb1 feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s
New MediaSession.Callback that grants CMD_TOGGLE_LIKE in onConnect
(Media3 issue #2679 guard) and routes onCustomCommand through
LikesRepository.toggleLike so notification/lock-screen/Pixel-Watch
taps inherit the offline-resilient MutationQueue path.

Unit tests cover the onConnect grant, current-state inversion in
both directions, no-op when there is no current MediaItem, and
rejection of unknown custom actions.

MinstrelPlayerService wiring lands in a follow-up commit.
2026-06-02 23:48:25 -04:00
bvandeusen ad7e57fe66 feat(android): scrubber thumb pill — fixes off-center perception
android / Build + lint + test (push) Successful in 4m0s
User report: the round dot didn't read as vertically centered on
the 4dp track even though geometrically it was (M3's SliderLayout
centers the track slot within the thumb's height). A small circle
on a thin horizontal bar is a known perceptual offset — the eye
expects the bar to bisect the circle, but the circle's mass extends
above and below in equal amounts the brain reads as a lift.

Swap the 14dp circle for a 4dp x 18dp vertical pill (CircleShape
on a non-square Box renders as a stadium). Same width as the track,
clearly taller — the bar visibly passes through the pill's
horizontal axis with no ambiguity. Also aligns with M3 expressive's
new vertical-handle slider direction.

Updates the ScrubTrack docstring that still referenced the prior
14dp-on-4dp pairing.
2026-06-02 22:48:40 -04:00
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 94b3b87785 fix(android): interceptor order — auth before baseUrl
android / Build + lint + test (push) Successful in 4m24s
Drift #568/#569 scoped AuthCookieInterceptor to PLACEHOLDER_HOST so
the shared OkHttp client wouldn't leak the session cookie to external
image fetches (Coil → musicbrainz, coverartarchive, Lidarr). The fix
was correct but assumed AuthCookieInterceptor would see the original
placeholder.invalid URL — production NetworkModule had BaseUrlInterceptor
running FIRST, so by the time auth's intercept() ran the host was
already rewritten to the real Minstrel server and the placeholder
check failed on every request.

Symptom on v2026.06.02: fresh install login appears to succeed but
no cookie is captured from Set-Cookie and no cookie is attached to
subsequent requests, so the user stays at the Welcome screen.

AuthCookieInterceptorTest already chains the interceptors in the
correct order, which is why the regression went undetected — only
production was wrong.

Fix: swap to (auth, baseUrl, logging). Auth now sees
placeholder.invalid, attaches/captures the cookie, then BaseUrl
rewrites the host for transport.
2026-06-02 21:47:53 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
android / Build + lint + test (push) Successful in 5m47s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen cb2f9a2ea2 fix(server): GC test seeds tracks with file_size + file_format
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 11m13s
Second go-round of the same shape of bug: tracks has file_size +
file_format NOT NULL (0002_core_library.up.sql) and my GC test seed
omitted both. The previous fix only addressed the artists.sort_name
column; the tracks INSERT was missing two more.

Use plausible stub values — the GC sweep only joins on track_id,
none of these columns affect what the test exercises.
2026-06-02 19:04:00 -04:00
bvandeusen 305d4780ac fix(server): TestGcCloseStalePlayEvents seeds artist with sort_name
test-go / test (push) Successful in 36s
test-go / integration (push) Failing after 14m34s
The artists table requires sort_name (NOT NULL constraint added by
0009_artist_sort.up.sql). My GC integration test was inserting only
name + relying on a separate SELECT to pull the id back, which both
(a) violated the NOT NULL constraint and (b) was unnecessarily
indirect. RETURNING the id directly is the standard pattern used
everywhere else in the test suite.

Test now matches the real-world insert pattern in api.search +
library scan (sort_name mirrors name when no MBID-driven sort hint
is available). Other GC tests in this file don't touch artists so
they were already fine.
2026-06-02 18:47:31 -04:00
bvandeusen dbcadf0f93 fix(android): drift #576 — LikesRepository uses real userId, clears on switch
android / Build + lint + test (push) Successful in 4m1s
Final drift audit finding (Scribe parent #552). LikesRepository
hardcoded LOCAL_USER_ID = "local" as the cached_likes discriminator
since before the auth slice landed. After auth shipped, the app
has a real per-user session but every device wrote rows under the
same "local" bucket — so sharing an Android device between two
Minstrel accounts left the previous user's likes visible to the
new user.

Changes:
- Inject AuthController + ApplicationScope so the repo can read
  the current user UUID and subscribe to user-switch events.
- `currentUserId()` resolves the cached_likes discriminator to
  `authController.currentUser.value?.id` with the legacy "local"
  fallback (ANONYMOUS_USER_ID, renamed from LOCAL_USER_ID) so
  pre-#576 cache rows from existing installs stay queryable until
  the first authenticated refreshIds() overwrites them.
- All eight call sites that used the constant now use the helper:
  observeLikedArtists/Albums/Tracks, observeIsLiked, likedTrackIds,
  toggleLike (optimistic upsert + delete), refreshIds (server
  replace).
- init {} subscribes to authController.currentUser; when the
  signed-in id changes, the OUTGOING user's rows get
  likeDao.clearForUser. Mostly a hygiene fix — the discriminator
  already prevents the wrong user from SEEING leaked rows, but
  without this they pile up forever as different accounts
  sign in/out on the same device.

This closes the final drift audit finding from the 2026-06-02 run.
26 of 26 candidate findings either confirmed-and-shipped (24) or
cancelled-as-duplicate (1) or shipped-with-honest-doc-fix (1).
2026-06-02 18:35:54 -04:00
bvandeusen 258bc1f75c feat(server): drift audit batch 7 — periodic GC worker for 5 lifecycle gaps
test-go / test (push) Successful in 29s
test-go / integration (push) Failing after 11m57s
New `internal/gc` package with a single Worker that runs all five
lifecycle / retention sweeps from the 2026-06-02 drift audit on a
1-hour tick. Each sweep is small, idempotent (re-running on
already-clean rows is a no-op), and logs its affected-row count.

Sweeps (Scribe parent #552):

- **#566** GcCloseStalePlayEvents — play_events rows opened > 24h
  ago that never got a play_ended (client crash, network drop).
  Synthesizes ended_at from duration_played_ms when known, falls
  back to now() so the row stops looking "open" to downstream
  filters (ended_at IS NULL).

- **#565** GcClosePlaySessionsWithNoRecentEvents — play_sessions
  with last_event_at older than 6h get ended_at = last_event_at
  ("user moved on"); empty sessions older than 1h get closed
  too (stale handshakes from clients that never recorded a play).
  The audit caught that the column was added but never populated
  by any writer — every session row was "open" forever, breaking
  downstream dedup queries that assume closed semantics.

- **#567** GcExpireScrobbleQueueFailedRows — drops scrobble_queue
  rows in status='failed' older than 14 days. The worker stops
  retrying after maxAttempts so these otherwise accumulate
  forever on a persistent ListenBrainz outage / revoked token.

- **#574** GcResetStuckSystemPlaylistRuns — flips
  system_playlist_runs.in_flight back to false on rows whose
  last_run_at is older than 10 minutes. Catches goroutine-panic
  wedges where the generator died between SET in_flight=true and
  SET in_flight=false; the duplicate-prevention check refuses to
  start a fresh regen while in_flight, so a stuck row would
  otherwise deadlock all future regens for that user. Records
  "stuck-row auto-reset by gc" in last_error so the operator can
  tell auto-reset from a recent real failure.

- **#575** GcDeleteExpiredPasswordResets — deletes expired
  password_resets rows. Unused expired rows go after a 1h grace
  (gives the operator time to debug an active reset attempt);
  used rows are kept 7 days for audit.

Wiring:
- main.go `go gcWorker.Run(ctx)` alongside the other periodic
  workers (scrobble, similarity, lidarr).
- tickOnce fires once at start so a freshly-deployed server does
  its initial sweep without waiting a full tick, matching the
  scrobble worker pattern.
- Errors per sweep are logged but do NOT abort the remaining
  ones — a transient pgx error from one query shouldn't prevent
  the others from running.

Tests:
- 4 integration tests, one per UPDATE/DELETE sweep, that seed
  rows-to-sweep + rows-to-leave-alone and assert the right rows
  changed state. Skip unless MINSTREL_TEST_DATABASE_URL is set
  (mirrors the api package pattern).
- Empty-tables no-op smoke test.
- Run() cancellation honoured (no spinning goroutine at
  test-runner exit).

That's all five remaining server-side lifecycle findings from the
audit. The Android LOCAL_USER_ID hardcode (#576) is a separate
refactor that needs auth-store wiring and stays in the queue.
2026-06-02 18:32:22 -04:00
bvandeusen bda0896d82 docs(server): drift #572 — delete.go honest about missing reconcile
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled
The docstring claimed "the next library scan reconciles missing
files by removing their tracks rows" — but scanner.go only does
filepath.WalkDir + UpsertTrack; it never enumerates existing rows
to check file_path presence, and it never DELETEs orphan rows. The
audit verified this — repo-wide grep finds no orphan-sweep code.

The lie is load-bearing: lidarrquarantine/service.go:270 leans on
this guarantee, so downstream code thinks the orphan case heals
itself. Fix the comment to state reality (admin re-trigger or
manual cleanup) and reference the open follow-up for adding a real
sweep. The actual reconcile pass is a separate piece of work
(needs scanrun integration + retention semantics + tests) and
stays in the Scribe audit queue.
2026-06-02 18:26:45 -04:00
bvandeusen 413d729711 fix(web): drift audit batch 5 — SSR redirect + radio exclude cap
test-web / test (push) Successful in 33s
Two web-side findings from the 2026-06-02 drift audit (#552):

- **#559** /library and /playlists each had a +page.server.ts
  file calling redirect(308, ...). The app is configured as
  adapter-static + ssr=false (+layout.ts:5), so +page.server.ts
  files only run at build time / dev server — NEVER at runtime in
  the deployed build. Direct navigation to /library or /playlists
  (mobile bookmarks, hand-typed URLs) hit a blank page or 404. We
  worked around this earlier today by linking the nav directly to
  /library/artists, but bookmarks stayed broken. Converted both
  files to +page.ts (universal load) — same redirect logic, runs
  client-side in the SPA, which is what actually executes.

- **#554** Radio auto-refresh built its exclude= query parameter
  from the ENTIRE queue, growing unbounded each refresh as new
  tracks were appended. UUIDs are ~36 chars + comma; with the
  common 8KB query-string limit, ~220 tracks is the ceiling. A
  multi-hour radio session eventually 414'd; the .catch() ate the
  error and the player silently stopped topping up — dead radio
  with no user-visible signal. Cap exclude to the most recent 100
  ids; the server's RecentlyPlayedHours filter already handles
  broader history dedup so the request-side cap only needs to
  cover the visible queue's recent tail.
2026-06-02 18:25:52 -04:00
bvandeusen b970b87343 fix(server): drift #578 — /api/me returns profile shape with display_name + email
test-go / test (push) Successful in 32s
test-go / integration (push) Has been cancelled
Server-side fix for the drift audit finding (Scribe #578, parent
#552). Mirrored on Android (and Flutter) but the root cause and
the smallest blast-radius fix both live here.

The bug:
- Android MeApi.getProfile() calls GET /api/me and deserializes
  into MyProfileWire which has nullable display_name + email.
- Server's handleGetMe was emitting the narrower UserView shape
  (id, username, is_admin only).
- Android always saw displayName=null, email=null. The Settings →
  Profile screen rendered BLANK form fields for users with stored
  values.
- Saving from the blank state submitted empty strings to
  PUT /api/me/profile, which interprets empty as "clear to NULL"
  (me_profile.go:53-65) — DESTROYING the user's saved profile.
- Flutter (flutter_client/lib/api/endpoints/settings.dart:9-12)
  has the identical bug pattern.

The fix:
- handleGetMe now emits profileViewFromUser(user) — the same
  shape PUT /api/me/profile already returns (meProfileResp:
  id, username, display_name, email, is_admin).
- auth.UserFromContext already returns a full dbq.User row, so no
  extra DB lookup needed.
- Web's User TypeScript type is narrower than this response but
  doesn't care about the extra fields (TS structural typing).
- LoginResp.User still uses UserView; login response unchanged.

New test asserts the regression directly: a user with stored
display_name + email sees them in /api/me. Old test updated to
decode into meProfileResp and assert the nullable fields are
correctly null for an unset profile.

Android side needs no change — the existing wire shape already
expected display_name + email; this just delivers them.
2026-06-02 18:24:23 -04:00
bvandeusen 5014f7548e fix: drift audit batch 3b — cold-boot resume correctness
android / Build + lint + test (push) Successful in 3m49s
Two related findings from the 2026-06-02 drift audit (#552):

- **#560 (Android)** PlayerController.setQueue() unconditionally
  called controller.play() at the end, with no way for
  ResumeController to opt out. Cold-boot resume therefore restored
  the persisted queue AND auto-started playback, which surprised
  users who had paused mid-track before backgrounding the app.
  Add `autoplay: Boolean = true` parameter; ResumeController
  passes false. Every existing setQueue call site continues to
  autoplay (the default is unchanged).

- **#562 (Android)** ResumeController.restore() ran from
  MinstrelApplication.onCreate alongside PlayerController's own
  init {} block that asynchronously binds the MediaController to
  MinstrelPlayerService. On fast devices with slow IPC the
  restore could land before mediaController was non-null;
  PlayerController.setQueue early-returns on null mediaController,
  so the restored queue was silently dropped — the user would
  open the app to an empty player after explicitly using "resume
  previous queue". Add `awaitReady()` suspend that completes when
  the MediaController binding lands; ResumeController awaits it
  before calling setQueue.

The two fixes ship together because the autoplay opt-out only
matters once the await fix guarantees the queue actually reaches
the player.
2026-06-02 18:21:39 -04:00
bvandeusen b19c621743 fix: drift audit batch 3a — Android offline correctness
android / Build + lint + test (push) Has been cancelled
Two findings from the 2026-06-02 drift audit (Scribe parent #552):

- **#577 (Android)** RequestsViewModel.cancel() called refresh() on
  BOTH Synced and Queued outcomes. Synced is fine (re-fetch the
  canonical list); Queued is offline by definition — the optimistic
  removal at the top of cancel() is already correct, and refresh()
  on Queued either (a) gets the not-yet-delivered cancelled row
  back from the server and snaps it into the list (confusing), or
  (b) fails with a transport error and flips the screen to
  UiState.Error so the user thinks the cancel failed even though
  it's queued. Gate refresh() on outcome == Synced; the mutation
  replayer reconciles when connectivity returns.

- **#570 (Android)** LikesRepository.refreshIds() pulled the
  server's likes list and INSERTed it into cached_likes — but
  never DELETEd local rows the server no longer surfaces. A
  cross-device unlike (user likes on web, then unlikes on web)
  left the entry visible on Android's Liked tab indefinitely with
  no way to clear short of wiping app data. Add
  CachedLikeDao.clearForUser + a @Transaction replaceAllForUser
  that atomically wipes-then-inserts the user's set; refreshIds()
  uses replaceAllForUser so the local cache is exactly what the
  server reports. The LOCAL_USER_ID hardcode is its own drift
  (#576) and stays for now — fixing it needs threading
  AuthStore.userId through the repo.
2026-06-02 18:18:59 -04:00
bvandeusen fb3116d640 fix: drift audit batch 2 — patterned fixes mirroring prior work
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Has been cancelled
test-go / integration (push) Has been cancelled
Five findings + one cancelled duplicate from the 2026-06-02 drift
audit (Scribe parent task #552):

- **#561 (Android)** PlayerController.playbackErrorEventsChannel was
  Channel.CONFLATED. The PlaybackErrorReporter coroutine reads it in
  a debounce loop that buffers events to coalesce into "Skipped N
  unplayable tracks" — but CONFLATED silently dropped every emission
  except the latest each time the reader wasn't actively pulling.
  A network blip that failed 5 tracks back-to-back surfaced only the
  last failure to the snackbar AND only POSTed one playback_errors
  row to the admin inbox. Switch to BUFFERED (default capacity 64,
  well above any plausible burst rate). Coalescing path now reaches
  N > 1 and the admin inbox sees every failure.

- **#563 (server)** systemPlaylistSources rotation whitelist in
  playevents/writer.go had drifted behind the migrations. It listed
  only for_you + discover; migrations 0021 + 0028 added 6 more
  variants (deep_cuts, rediscover, new_for_you, on_this_day,
  first_listens, songs_like_artist) that ship as refreshable system
  mixes. Plays from those surfaces never advanced the per-user
  rotation, so "unplayed first" ordering staled — the same tracks
  kept resurfacing. Add all 6 to the map; comment now points at the
  migration's CHECK list as the canonical source so future variants
  notice the requirement. #573 was the duplicate auditor hit for
  the same drift; cancelled in Scribe.

- **#564 (Android)** Android emitted source = "playlist:<variant>"
  for system-mix plays from Home and PlaylistDetail, but the
  server's rotation matcher keys on the BARE variant string (web
  sends the bare form — PlaylistCard.svelte:83). Misalignment meant
  system-mix plays from Android never advanced rotation; switching
  from web to Android effectively reset the perceived "unplayed
  next" ordering. Fix HomeScreen.kt:291 to send bare variant and
  PlaylistDetailScreen.kt's play() to prefer systemVariant over the
  playlist:<id> tag when the playlist is a refreshable system mix.
  User playlists keep playlist:<id> (intentional — rotation only
  applies to system mixes anyway).

- **#568 + #569 (Android)** AuthCookieInterceptor was unconditionally
  attaching the Minstrel session cookie to every outgoing request
  AND wiping the session on any 401. The shared OkHttpClient is also
  used by Coil for external image fetches (artwork.musicbrainz.org,
  coverartarchive.org, Lidarr /MediaCover URLs); this leaked the
  session cookie to those hosts (privacy posture) AND silently
  signed users out of Minstrel if any external image host returned
  401. Scope both attach + clear to the placeholder.invalid sentinel
  host the same way BaseUrlInterceptor was scoped in aec10ce7. Two
  new regression tests cover the external-host pass-through. Existing
  tests rewritten to make requests through the placeholder URL so
  they exercise the in-scope path explicitly.

All five Scribe tasks updated to in_progress at start, will flip to
done after CI green on this push.
2026-06-02 18:16:29 -04:00
bvandeusen 47d2f61161 fix: drift audit batch 1 — six small mechanical wins
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 42s
test-go / integration (push) Has been cancelled
Six findings from the 2026-06-02 multi-system drift audit (Scribe
parent task #552):

- **#553 (web)** Tailwind class fix: web admin playback-errors Delete
  confirm button was using `bg-action-danger`, an undefined token —
  swap to `bg-action-destructive` to match every other destructive
  button. Restored the Oxblood signal that distinguishes Delete from
  Cancel.

- **#555 (web)** Type the `source` field on `play_started` in the
  EventRequest discriminated union. Server's eventRequest accepts it;
  web's TS type was missing the slot, so a "drop extra properties"
  refactor could silently strip the source tag and break system-
  playlist rotation attribution.

- **#556 + #557 (server)** Coverage rollup whitelist was pinned to
  ('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added
  'deezer' and 'lastfm' as valid cover_art_source values but those
  never got wired in, so albums with art from those providers
  silently counted as MISSING in the admin Coverage dashboard. The
  rollup test was seeding only the pre-0020 sources, masking the
  gap in CI. Extend the query to include deezer + lastfm; seed the
  test with one row per valid source (regression-guards future
  additions).

- **#558 (web)** Auth gate was blocking /forgot-password and
  /reset-password/<token> — both are entered without a session by
  definition, so the email-link reset flow was bouncing signed-out
  users to /login. Add /forgot-password to the public set and a
  /reset-password/ prefix matcher. New tests assert both routes
  reach their pages without redirect.

- **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac
  /.ogg while the stream handler (media.go) had been extended to
  serve .opus, .aac, and .wav. A user with .opus files in their
  library never saw them in artist/album listings because the
  scanner skipped indexing — silent data loss. Aligned the scanner
  to match the media handler.

Scribe statuses updated to in_progress; flipping to done after the
push since these are mechanical and verified directly against the
cited file:lines.
2026-06-02 18:11:25 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen 9f0af9c24b feat(android): CI-injected versionName with commit-count iteration
android / Build + lint + test (push) Successful in 4m12s
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).

Scheme:
- Per release: versionName = "${tag}.${commit_count}", e.g.
  "2026.06.02.142", where commit_count = `git rev-list --count HEAD`.
  Monotonic across the project lifetime, deterministic, no manual
  counter to maintain.
- versionCode = commit_count. Monotonic, fits in Int forever (we're
  not hitting 2.1B commits).
- Local / debug / dev builds fall back to versionName="dev" /
  versionCode=1 so the About card reads honestly.

build.gradle.kts:
- defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE
  Gradle properties via project.findProperty with the dev fallbacks.

.gitea/workflows/release.yml:
- android-release: checkout with fetch-depth: 0 (the default shallow
  clone would return 1 for `git rev-list --count HEAD`); new
  Compute release version step exports name + code as step outputs;
  assembleRelease passes them via -P; new job-level outputs propagate
  them to the downstream image-release job.
- image-release: Stage bundled APK + version sidecar pulls the
  computed version_name from needs.android-release.outputs and
  writes it into client/minstrel.apk.version, so the server's
  /api/client/version reports the exact string baked into the APK.
  Without this the sidecar would say "v2026.06.02" while the
  installed APK has "2026.06.02.142" — isVersionNewer would call
  the bundled APK older and the update banner would thrash.

The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
2026-06-02 14:07:57 -04:00
bvandeusen 76e64fd022 fix(android): close PlaybackErrorsApi kdoc — Kotlin nested-comment trap
android / Build + lint + test (push) Successful in 4m55s
The kdoc on PlaybackErrorReportRequest mentioned the existing
/api/plays/* endpoint. Kotlin's lexer treats /* inside a /** ... */
kdoc as a NESTED comment opener, which then swallows the outer
*/ — so the entire PlaybackErrorReportRequest data class
disappeared from the symbol table and the four call sites in
PlaybackErrorsApi.kt / MutationReplayer.kt / PlaybackErrorRepository.kt
all reported "Unresolved reference".

This is the trap recorded in the project's KSP-could-not-be-
resolved memory; mark it again. Fix is mechanical: rewrite the
prose as `/api/plays/...` so no /* sequence appears inside a
block comment.
2026-06-02 11:51:19 -04:00
bvandeusen 337fce83a1 fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s
Three rule trips from the playback-errors + scrubber commits:

PlayerController.startRadio (4 returns → 2): extract the mid-queue
append branch into appendRadioToQueue(). startRadio just does the
guard checks and dispatches; the helper handles the cursor trim +
addMediaItems. Behavior identical.

PlayerController.onPlaybackStateChanged (4 returns → 2): extract
the duration check + zero-duration error emission + skip logic
into handleZeroDurationIfNeeded(). The listener stays compact (one
return for non-READY, one for repeat-evaluation guard); the helper
owns the failure path.

NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
2026-06-02 11:45:45 -04:00
bvandeusen de61305fde feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.

Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
  "no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
  / detail / reporter / client_id / occurred_at. Matches the
  operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
  /api/admin/quarantine/{track_id}/delete-file endpoint AND
  auto-stamps resolution='deleted' so a single click closes both
  the file and the inbox row.

Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).

Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
2026-06-02 11:34:46 -04:00
bvandeusen 99e1df4920 feat(android): detect zero-duration tracks, fail fast, report to server
android / Build + lint + test (push) Failing after 2m7s
Operator hit a track that loaded with zero duration; player just sat
on it. Two things needed: skip the dead track immediately, and tell
the server so the admin inbox can surface the bad file.

PlayerController:
- Player.Listener.onPlaybackStateChanged(STATE_READY) now checks
  duration. If it's <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent
  with kind="zero_duration" and calls seekToNextMediaItem (or stop
  if it was the last item). Per-item evaluation guard keeps repeat
  STATE_READY events (post-seek, post-resume) from re-firing.
- onPlayerError now also surfaces a PlaybackErrorEvent with
  kind="load_failed" + the Media3 exception message as detail.
- playbackErrorEvents flow changes from Flow<String> (title only) to
  Flow<PlaybackErrorEvent> (track_id + kind + title + detail) so
  downstream consumers can both surface a snackbar AND POST to the
  admin inbox without duplicating event emission.

PlaybackErrorRepository (new):
- Wraps POST /api/playback-errors with the offline-first MutationQueue
  fallback per the standing rule for server writes.
- Reuses AuthStore.clientId for the client_id field — same UUID-per-
  install identifier the play-events reporter sends, so support can
  correlate playback errors with surrounding plays.

PlaybackErrorReporter:
- Consumes the new richer event shape. Fires the server report per
  event (no debounce — the admin inbox should capture every report,
  not a coalesced summary). Continues to debounce the user-facing
  snackbar in the 2s window so a burst doesn't spam toasts.

MutationQueue / MutationReplayer:
- Adds PLAYBACK_ERROR_REPORT kind + PlaybackErrorReportPayload +
  enqueuePlaybackErrorReport entry point + replayer dispatch case
  hitting the new PlaybackErrorsApi.

Web admin inbox + UI is the next commit.
2026-06-02 11:30:35 -04:00
bvandeusen 15b59a214d feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s
New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.

Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
  resolution enums (per the standing rule that new enum values
  need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic

Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
  validates track existence + kind whitelist; client_id required
  so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
  admin list with join to track/album/artist for table render
  without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
  resolved with a resolution enum string.

Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
2026-06-02 11:25:50 -04:00
bvandeusen 6a54d074fd revert(android): drop NowPlaying scrubber height clamp
android / Build + lint + test (push) Failing after 1m34s
Operator: the slider height clamp shifted the surrounding layout
above and below — not what they intended to change. Revert the
Modifier.height(20.dp) and remove the SCRUB_SLIDER_HEIGHT_DP
constant; the Slider goes back to its M3-default 48dp interactive
component height so adjacent rows sit where they did before.

The slim 4dp custom track stays — that's what addresses the
"puffy bar" feel — and the thumb still sits on it as a visible
14dp circle, with 17dp empty vertical space above and below.
That's the M3 standard layout the operator wants restored.
2026-06-02 10:28:27 -04:00
bvandeusen d9c7aae268 feat(android): slim NowPlaying scrubber — 4dp track + tight slider
android / Build + lint + test (push) Failing after 1m44s
The M3 Slider default track is 16dp tall and the Slider itself
expands to the 48dp interactive-component minimum, so the 14dp
thumb we'd already shrunk to a flat circle was still sitting in
the middle of a fat horizontal pill with lots of empty space
above and below. Operator framing: "puffy, not a tool."

Two changes:
- Custom 4dp rounded track replaces SliderDefaults.Track. The
  thumb (14dp) now reads as visibly taller than the bar — the
  classic "handle on a string" cue that says "tool, draggable."
  Also drops M3's stop-indicator dot which the web scrubber
  doesn't have.
- Clamp the Slider's vertical footprint to 20dp via Modifier.
  height. 14dp thumb + 3dp clearance each side, vs the default
  ~17dp empty above and below. Touch area stays usable since the
  drag axis is horizontal — pulling left/right anywhere on the
  thin bar feels natural, and Slider's gesture detector still
  responds to a tap anywhere along its row.

Keeps an Android flavor (slightly thicker than the web's 2-3px
hairline; rounded caps; accent fill) without reading as bulky.
2026-06-02 10:24:31 -04:00
bvandeusen 5fd1a5724a fix(android): always use dominant swatch for NowPlaying background
android / Build + lint + test (push) Has been cancelled
Operator framing: the cover art is the main feature of NowPlaying,
not the background. A vibrant accent on the cover (small bright
logo, sticker, stripe) should pop against the background, not be
matched by it. The previous vibrant → muted → dominant fallback
chain often picked a high-saturation accent that covered only a
sliver of the cover, producing gradients that clashed with the
actual image.

Drop to dominantSwatch only — the majority-by-pixel-count color.
If the palette resolves no dominant swatch (extremely rare;
essentially uniform/empty bitmap) the held color stays on the
previous track's dominant, matching the existing "keep previous
on failure" docstring contract.
2026-06-02 10:24:19 -04:00
bvandeusen 516d22fc73 feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s
Operator: tapping Start Radio while music plays previously
reloaded the current track from position 0 because the radio
seed response includes the seed at index 0 and the handler called
setQueue(tracks, 0) — Flutter's playerActions.startRadio does the
same. They want the current track to keep playing untouched,
upcoming queue cleared, radio results appended after.

PlayerController.startRadio now branches on mediaItemCount:
  - Empty queue: existing behavior — setQueue from index 0.
  - Active queue: keep currentMediaItem, removeMediaItems from
    currentIdx+1 to end, then addMediaItems with the radio list.
    When the seed is the currently-playing track (the common
    "Start Radio on the song I'm listening to" case), drop the
    seed from the appended list so it doesn't immediately repeat
    after the current track ends.

queueRefs is updated alongside the controller so the cached
TrackRef list stays consistent. Source tag "radio:<id>" is
preserved for the appended items so play_started attribution
stays correct.

Intentional divergence from Flutter — recorded in the docstring
so future ports notice it.
2026-06-02 10:09:00 -04:00
bvandeusen 1fdd785ee5 fix(android): key UiState Crossfades on state::class to stop refresh flicker
android / Build + lint + test (push) Has been cancelled
Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."

HomeScreen already keys on `state::class` (4b9d-ish prior fix);
apply the same pattern to the four screens that still flicker.
Inner content reads the outer `state` directly via `val s = state`
so the branch still has access to the typed value. Row-level diffs
are owned by LazyVerticalGrid / LazyColumn via item keys, so the
visual update is smooth and granular instead of a full fade.

Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
2026-06-02 10:07:10 -04:00
bvandeusen 9b3ec65476 feat(android): swipe to change Library tabs
android / Build + lint + test (push) Has been cancelled
Operator: tabs in the Library view should feel swipeable, not just
tappable. Replace the selectedTab Int state + when-block content
with HorizontalPager whose state drives the PrimaryScrollableTabRow.
Tap routes through animateScrollToPage so swipe + tap share one
source of truth.

Horizontal pager gestures don't conflict with the LazyVerticalGrid
inside each tab (different axes) or with PullToRefreshScaffold's
vertical pull (different axes). HorizontalPager renders only the
current page by default; adjacent tabs remain composed during the
swipe but not eager-mounted at start.
2026-06-02 10:04:38 -04:00
bvandeusen b83a6a4bdb revert(android): top-nav Library icon back to Lucide.LibraryBig
android / Build + lint + test (push) Has been cancelled
Operator polled another user and reversed the earlier swap to
Material's LibraryMusic. Restore Lucide.LibraryBig and drop the
material-icons-extended Gradle dependency we added for the
intermediate icon, keeping the icon set Lucide-only.
2026-06-02 10:03:15 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen 4cd38aa62f fix(android): keep BaseUrlInterceptor.intercept under detekt ReturnCount
android / Build + lint + test (push) Successful in 4m18s
aec10ce7 added a third early return (the placeholder-host bail)
on top of the existing unparseable-baseUrl elvis return, tripping
detekt's ReturnCount ceiling of 2 on the dev test workflow.

Refactor: keep the placeholder-host early bail (it preserves the
no-op cost for external URLs — no AuthStore read, no URL parse),
fold the unparseable-baseUrl case into a `?:` that falls back to
the original URL. Result is two returns and identical observable
behavior — placeholder hosts get rewritten when baseUrl parses,
fall through unchanged when it doesn't.

Existing unit tests cover all three paths and continue to assert
the same outputs.
2026-06-02 09:50:05 -04:00
bvandeusen 438e81a117 feat(android): media-notification tap opens NowPlaying
android / Build + lint + test (push) Failing after 1m32s
Tapping the system media notification previously landed on
whatever shell route MainActivity last rendered (Home / Library /
Search) because MinstrelPlayerService never configured the
session-activity PendingIntent, so Media3 defaulted to the
launcher activity entry point. Operator request: tap should go
straight to the full player.

MinstrelPlayerService.onCreate now builds a PendingIntent
targeting MainActivity with an EXTRA_OPEN_NOW_PLAYING flag and
passes it to MediaSession.Builder.setSessionActivity. The flag
also covers the lock-screen card and the Pixel Watch tile —
both use the same session-activity PendingIntent.

MainActivity reads the extra in onCreate AND onNewIntent (so a
warm app gets the navigation too, not just cold launches), flips
a pendingOpenNowPlaying StateFlow, then strips the extra so a
config-change recreation doesn't re-trigger. The App composable
observes the flag and runs a LaunchedEffect to navigate once the
NavHost is mounted — handles both cold start (BootSplash →
resolved → navigate) and warm start. launchSingleTop avoids
stacking copies if NowPlaying is already on top, and the
onOpenedNowPlaying callback clears the flag post-navigation so
later recompositions don't re-fire.

Divergence from Flutter (intentional): audio_service's default
notification tap behavior just opens the launcher activity at
whatever screen it was on — exactly the behavior the operator
asked to improve.
2026-06-02 09:43:45 -04:00
bvandeusen faf2cac0c9 feat(android): use Material Outlined LibraryMusic for top-nav Library
android / Build + lint + test (push) Failing after 2m34s
Lucide has no music-library glyph — Library / LibraryBig /
SquareLibrary all read as a generic books-on-shelf icon without
a label. Operator picked Material's LibraryMusic (the canonical
"books + music note" symbol used by every major music app) as
the recognizable alternative.

Use the Outlined variant: filled icons would clash with the
neighbouring stroked Lucide icons (House, Search, EllipsisVertical),
but Outlined's stroke style matches Lucide closely enough that
the mix is subtle.

Adds the compose-material-icons-extended dependency (version
pinned by compose-bom). R8 strips unused icons in release builds
so the APK cost is just the ones we actually reference.
2026-06-02 09:40:49 -04:00
bvandeusen 22dc343b39 feat(android): swap top-nav Library icon to Lucide.SquareLibrary
android / Build + lint + test (push) Failing after 1m25s
LibraryBig (stacked book spines) didn't read as "Library" without
the label — easy to misread as a generic stack/columns icon.
SquareLibrary frames the same books-on-shelf glyph inside a
rounded square, matching the visual weight of the neighbouring
House and Search icons better and reading more clearly as a
distinct tappable destination.

Untouched: the RequestsScreen per-row "album"-kind avatar still
uses LibraryBig (parity with Flutter's lib/requests/requests_screen.dart).
2026-06-02 09:35:58 -04:00
bvandeusen aec10ce787 fix(android): scope BaseUrlInterceptor to placeholder host only
android / Build + lint + test (push) Failing after 1m41s
Discover suggestion artist images failed to load on Android while
loading fine in the web client. Root cause: BaseUrlInterceptor
unconditionally rewrote every outgoing request's scheme/host/port
to AuthStore.baseUrl. That's correct for Minstrel-bound requests
built with the http://placeholder.invalid sentinel (Retrofit's
frozen baseUrl, every cover-URL builder, ServerImage's
resolveServerUrl). But Lidarr surfaces artist artwork as absolute
URLs to external hosts (artwork.musicbrainz.org,
coverartarchive.org); rewriting those to the Minstrel host
produced 404s that Coil silently fell back from to the User icon.

Web works because the browser fetches the URL as authored. Coil
on Android shares the OkHttp client (and so the interceptor chain)
with Retrofit, which is why the bug surfaced here only.

Add a PLACEHOLDER_HOST companion constant and short-circuit the
rewrite for non-placeholder hosts. Test coverage:
- placeholder host → rewritten to live baseUrl
- absolute external URL → host/scheme/path preserved
- unparseable baseUrl → falls through (no throw)

AuthCookieInterceptor still attaches the Minstrel session cookie
to external requests; external hosts ignore unrecognized cookies
so that's not breaking anything, but it's worth a follow-up DRY
pass to scope auth attachment the same way.
2026-06-02 09:16:36 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen b9186937b3 chore(android): lock MainActivity to portrait
android / Build + lint + test (push) Successful in 3m39s
Operator feedback: landscape just stretches the phone-portrait
Compose layout awkwardly — every screen was sized for one column
of cards, so rotation produces wide rows of unrelated content with
big dead bands top and bottom. Until a dedicated tablet/landscape
layout exists, lock the activity to portrait via screenOrientation.

Revisit when a sw600dp resource set + multi-pane layouts land.
2026-06-02 08:12:58 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 1004b61159 fix(android): keep onPostScroll under detekt ReturnCount limit
android / Build + lint + test (push) Successful in 3m43s
The dismissed-latch added a third early return to onPostScroll —
detekt's ReturnCount ceiling is 2 per the project rule. Fold the
NestedScrollSource.UserInput guard into the existing if/else if/else
chain that branches on the drag direction. Behavior is identical;
the source check just becomes the first arm of the expression
rather than an early bail.
2026-06-02 08:04:21 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 6ac36dd334 fix(web): user-playlist play omits third arg to satisfy contract test
test-web / test (push) Successful in 35s
74bae74f routed per-artist system mixes through getPlaylist and
always passed the source attribution as the third arg, falling
back to undefined for true user playlists. Vitest's toHaveBeenCalledWith
is arity-strict — playQueue(refs, 0, undefined) is not the same as
playQueue(refs, 0) — so the PlaylistCard contract test failed on
the user-playlist case.

Split the call: pass three args only when a variant tag exists,
two args for user playlists. Preserves source attribution for
songs_like_artist and keeps user playlists source-less as the
test pins.
2026-06-01 23:31:45 -04:00
bvandeusen 6a7d9afdbc fix(android): latch NowPlaying drag-dismiss to prevent back-stack underflow
android / Build + lint + test (push) Failing after 1m24s
Operator reproduced black-screen-on-resume by drag-down dismissing
the full player. Root cause: the NestedScrollConnection accumulator
crossed the dismiss threshold, called navController.popBackStack(),
reset accumulated to 0 — but the user's finger was still down and
the pop transition was still running. The next frame's onPostScroll
re-accumulated and re-fired onDismiss(), popping the screen BENEATH
NowPlaying. When that left the back stack empty the NavHost had no
destination to draw, producing a black window until the process
was killed and the activity was cold-launched.

Add a `dismissed` latch that survives until the connection is
disposed (which only happens when NowPlayingScreen leaves the
composition, i.e. the pop completes). After the latch sets we
consume the remaining drag (return `available`) so the underlying
scrollable doesn't paint over-scroll while the pop transitions.
onPreFling also bails after dismissal so the fling can't restart
the accumulator.
2026-06-01 23:29:36 -04:00
bvandeusen 15a545a25c feat(web): two-pane now-playing on desktop with permanent queue panel
test-web / test (push) Failing after 33s
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:

- Extract QueueList from QueueDrawer (header + count + scrollable
  list body + empty state); the drawer now wraps QueueList for its
  slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
  (xl: 448px) aside with QueueList. Below lg the layout is unchanged
  single-column and the drawer toggle in the bottom row still opens
  it (lg:hidden on the toggle button keeps it out of the way once
  the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
  and the bind:closeButtonRef for focus management; embedded panel
  omits both since it has no dismiss action.

QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
2026-06-01 23:24:06 -04:00
bvandeusen b77a7121ca feat(android): restyle NowPlaying scrubber thumb to match web client
android / Build + lint + test (push) Successful in 3m39s
User feedback: dislikes both the old M3 20dp thumb and the current
10dp slim variant; wants the bare HTML range thumb the web client
shows. Replace SliderDefaults.Thumb with a plain Box(CircleShape +
accent fill, 14dp). Drops the M3 state-layer halo on press and the
implicit elevation/border so the on-screen result matches a
<input type=\"range\" accent-color> rendering. Slider's 48dp hit
slop is intrinsic to the composable, so tapability is unchanged.
2026-06-01 22:40:35 -04:00
bvandeusen 74bae74f9f fix(web): songs-like play overlay + scrubber smoothing
test-web / test (push) Failing after 45s
PlaylistCard: per-artist system variants (songs_like_artist) all share
one variant tag but exist as one playlist per seed artist; routing
their play handler through systemShuffle(variant) hit the wrong
playlist (or 404). Detect via seed_artist_id != null and fall through
to getPlaylist(playlist.id) for those; still tag the queue with the
variant so source attribution stays correct.

smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors
Android's rememberSmoothPositionMs. player.position only updates
~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly;
$effect resets on each canonical tick / seek / track-change and a
rAF loop extrapolates at playback rate between ticks.

Wired into both PlayerBar.svelte (mini + expanded seek rows) and
now-playing/+page.svelte. Seek input handler still reads the raw
range value (not smoothed.value) so user drags stay authoritative.
2026-06-01 22:39:24 -04:00
bvandeusen d4c6bb3f2d feat(web): alphabet rail chases pages on click for unloaded letters
test-web / test (push) Successful in 32s
Operator: prior rail disabled empty buckets, so clicking a letter
like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now
accepts a paginated source and walks pages on click until the
target letter surfaces.

- New props: hasMore, onLoadMore. When hasMore=true, every empty
  bucket stays enabled (clicking will chase pages); when hasMore=
  false, only populated buckets are clickable.
- jumpTo(bucket): if populated, scrollIntoView. Otherwise loop
  onLoadMore + tick() until the bucket appears or no more pages.
  Loader2 spinner replaces the letter on the pending button;
  cursor:wait + aria-busy. Other rail buttons disable while one is
  pending so clicks don't stack.
- Library Artists + Albums pass hasMore + onLoadMore through to the
  grid. Existing InfiniteScrollSentinel still handles scroll-driven
  loading.

Test: new case asserts rail enables empty buckets when hasMore=true
(the click would trigger onLoadMore).
2026-06-01 22:29:13 -04:00
225 changed files with 15632 additions and 2125 deletions
+36 -3
View File
@@ -60,9 +60,35 @@ jobs:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
# Job outputs propagate the computed release version to image-release
# so the bundled sidecar file matches what's baked into the APK —
# otherwise the server would report a different version string than
# the installed client and the update banner could thrash.
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
fetch-depth: 0
- name: Compute release version
id: ver
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
- name: Cache Gradle dirs
uses: actions/cache@v4
@@ -91,7 +117,10 @@ jobs:
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
run: ./gradlew assembleRelease
run: |
./gradlew assembleRelease \
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact
@@ -204,14 +233,18 @@ jobs:
- name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
run: |
set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}"
# The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk
echo "${TAG}" > client/minstrel.apk.version
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/
- name: Build and push
+21
View File
@@ -116,6 +116,27 @@ jobs:
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Relax durability on the throwaway CI Postgres. Our test pattern
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
# every test, and the per-TRUNCATE commit fsync is the dominant
# cost of the integration suite. The CI DB is rebuilt every run so
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
# via docker exec because:
# - The act_runner `services:` block can't override the container
# command, so `postgres -c fsync=off` at boot isn't an option.
# - ALTER SYSTEM cannot run inside a transaction; psql -c
# auto-commits each statement, which is what we need.
# - fsync / full_page_writes are sighup GUCs and
# synchronous_commit is user-context, so pg_reload_conf() picks
# all three up with no restart.
# Non-fatal: a perms surprise degrades to "slower", never red CI.
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
-c "ALTER SYSTEM SET fsync = off" \
-c "ALTER SYSTEM SET synchronous_commit = off" \
-c "ALTER SYSTEM SET full_page_writes = off" \
-c "SELECT pg_reload_conf()" \
|| echo "WARN: durability relax failed; continuing"
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
+13
View File
@@ -1754,6 +1754,19 @@
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
+20 -2
View File
@@ -21,8 +21,19 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0-native"
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = versionCodeOverride ?: 1
versionName = versionNameOverride ?: "dev"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
@@ -162,6 +173,7 @@ dependencies {
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.datasource.okhttp)
implementation(libs.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
@@ -175,6 +187,12 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
// classpath. Android's stock XmlPullParserFactory resolves to the
// android.jar Stub on JVM tests; kxml2 is picked up via service-
// provider lookup and makes XmlPullParserFactory.newInstance() work
// unconditionally so DeviceDescriptionTest runs in CI.
testImplementation(libs.kxml2)
// kotlin.test for assertEquals/assertNull/etc. — version managed by
// the applied Kotlin plugin so no explicit version pin needed.
testImplementation(kotlin("test"))
@@ -0,0 +1,690 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "cached_history_snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
},
{
"fieldPath": "themeMode",
"columnName": "themeMode",
"affinity": "TEXT"
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT"
},
{
"fieldPath": "cacheSettingsJson",
"columnName": "cacheSettingsJson",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
]
}
}
+7
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:name=".MinstrelApplication"
@@ -22,9 +24,14 @@
android:usesCleartextTraffic="true"
tools:targetApi="34">
<!-- Portrait-locked until a tablet/landscape layout exists.
Current Compose screens are sized for phone-portrait;
landscape just stretches the column awkwardly. Revisit
this when a dedicated tablet layout lands. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Minstrel">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -19,24 +21,69 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
// composable observes this, navigates to NowPlaying once the
// NavHost is ready, then calls back to reset the flag so the
// navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
consumeOpenNowPlayingIntent(intent)
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeOpenNowPlayingIntent(intent)
}
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
}
companion object {
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
* so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
}
}
@@ -44,15 +91,21 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination
@@ -65,6 +118,18 @@ private fun App(
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of
// NowPlaying if the user taps the notification while
// already on it; the callback clears the flag so a later
// recomposition (config change, theme switch) doesn't
// re-navigate.
LaunchedEffect(pending, navController) {
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
}
MinstrelNavGraph(
navController = navController,
startDestination = resolved,
@@ -19,7 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -114,12 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
* shell-level VersionTooOldBanner can surface min_client_version
* mismatches without waiting for the next user-driven request.
* Same construct-the-singleton trick — NetworkStatusController owns the
* /healthz poll loop + the device-link collector + the reachability state
* machine, and is the single authority on the tri-state ServerHealth
* signal (plus the VersionTooOld byproduct). It must exist from launch so
* the poll loop runs and the StateFlow stays warm for every consumer.
*/
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
@@ -158,10 +159,43 @@ class MinstrelApplication :
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production.
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(ReleaseTree())
}
appScope.launch { resumeController.restore() }
}
/**
* Release-build Timber tree: emits at WARN and above only.
* `android.util.Log` with the canonical tag so `adb logcat` shows
* the line under the standard tag column without falling through
* to the package-stack-trace tag DebugTree produces.
*/
private class ReleaseTree : Timber.Tree() {
override fun isLoggable(tag: String?, priority: Int): Boolean =
priority >= android.util.Log.WARN
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val resolvedTag = tag ?: "Minstrel"
if (t == null) {
android.util.Log.println(priority, resolvedTag, message)
} else {
android.util.Log.println(
priority,
resolvedTag,
message + '\n' + android.util.Log.getStackTraceString(t),
)
}
}
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -7,11 +7,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Attaches the session cookie from [AuthStore] to every request,
* captures Set-Cookie from successful responses (login flow), and
* clears the store on 401 so downstream code can react to logout.
* Attaches the session cookie from [AuthStore] to Minstrel-server
* requests, captures Set-Cookie from successful responses (login
* flow), and clears the store on 401 so downstream code can react
* to logout.
*
* Mirrors the Flutter Dio interceptor pattern.
*
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
* leaks here: (1) the session cookie was being attached to every
* external request the shared OkHttpClient services — including
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
* / Lidarr's /MediaCover endpoints — exposing the session
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
* closes both gaps.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
@@ -19,8 +31,15 @@ class AuthCookieInterceptor @Inject constructor(
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (original.url.host != BaseUrlInterceptor.PLACEHOLDER_HOST) {
// External request — no Minstrel session cookie attached,
// and a 401 from this host does NOT clear the user's
// session. Pass through untouched.
return chain.proceed(original)
}
val cookie = authStore.sessionCookie.value
val request = chain.request().newBuilder().apply {
val request = original.newBuilder().apply {
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
}.build()
@@ -9,16 +9,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Rewrites every outgoing request's scheme/host/port to match the
* Rewrites Minstrel-server requests' scheme/host/port to match the
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
* asynchronously — so at injection time the Retrofit instance is
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
*
* This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual request gets retargeted at
* the live AuthStore value. Lets the user change server URL in
* Settings without an app relaunch (Phase 11 wiring).
* placeholder forever, and every actual Minstrel-bound request gets
* retargeted at the live AuthStore value. Lets the user change
* server URL in Settings without an app relaunch (Phase 11 wiring).
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the
* Minstrel host produced 404s the user saw as missing Discover
* suggestion covers.
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
@@ -31,13 +38,26 @@ class BaseUrlInterceptor @Inject constructor(
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
?: return chain.proceed(original)
val rewritten: HttpUrl = original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
// Early-bail keeps the no-op path for external URLs cheap
// (no AuthStore read, no URL parse).
if (original.url.host != PLACEHOLDER_HOST) return chain.proceed(original)
// Unparseable baseUrl folds into the same proceed path — keeps
// detekt's ReturnCount happy by avoiding a second early return.
val rewritten: HttpUrl = authStore.baseUrl.value.toHttpUrlOrNull()?.let { baseUrl ->
original.url.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
} ?: original.url
return chain.proceed(original.newBuilder().url(rewritten).build())
}
companion object {
/** Sentinel host used by Retrofit + every code path that builds
* a Minstrel-server URL via the `http://placeholder.invalid/...`
* form (see [com.fabledsword.minstrel.shared.resolveServerUrl],
* CoverUrls.kt, AlbumRef/TrackRef cover getters). */
const val PLACEHOLDER_HOST = "placeholder.invalid"
}
}
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
@@ -45,14 +46,29 @@ object NetworkModule {
fun provideOkHttp(
baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor,
reachability: ReachabilityReportingInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
// BaseUrlInterceptor first it rewrites scheme/host/port
// to match the live AuthStore.baseUrl on every request.
// Auth then sees the final URL.
.addInterceptor(baseUrl)
// ReachabilityReportingInterceptor MUST run first: it identifies
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
// (so external artwork fetches don't read as server reachability)
// and observes the final transport outcome by wrapping the chain.
.addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches
// (drift #568 / #569). If BaseUrlInterceptor runs first it
// rewrites the host to the real server before auth sees the
// request, auth's placeholder check fails, and the session
// cookie is neither attached on outgoing requests nor
// captured from Set-Cookie on login — fresh installs get
// stuck at the Welcome screen. Auth first means it sees
// placeholder.invalid, attaches/captures correctly, then
// BaseUrlInterceptor retargets to the live AuthStore host
// for transport.
.addInterceptor(auth)
.addInterceptor(baseUrl)
.addInterceptor(logging)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -0,0 +1,56 @@
package com.fabledsword.minstrel.api.endpoints
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for the cast-token endpoint. Used by the UPnP
* selection path in `OutputPickerController` to obtain a signed stream
* URL that a network speaker can fetch without the user's session
* cookie — those devices cannot carry the session, so the signed
* query string is the only way they can fetch the bytes.
*
* Endpoint: `POST /api/cast/stream-token`
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
*
* Server contract lives at `internal/api/cast_token.go`
* (commit e774097f). Field names here mirror the server's `json:`
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
* needed on the request, and `token` / `exp` / `url` map straight
* through on the response.
*/
interface CastApi {
@POST("api/cast/stream-token")
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
}
/**
* Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback.
*/
@Serializable
data class StreamTokenRequest(
val trackId: String,
val expSeconds: Int = 21_600,
)
/**
* Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
* the client needs to build DIDL-Lite metadata for that call: Sonos
* rejects empty DIDL with vendor error 1023, so the server hands back
* the track's MIME (from `tracks.file_format`) and title so the
* client can populate `<res protocolInfo>` and `<dc:title>` without
* a follow-up round trip.
*/
@Serializable
data class StreamTokenResponse(
val token: String,
val exp: Long,
val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
)
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
@@ -35,9 +36,26 @@ interface LibraryApi {
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/artists/{id}/similar")
suspend fun getSimilarArtists(
@Path("id") id: String,
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
): List<ArtistWire>
@GET("api/artists/{id}/top-tracks")
suspend fun getArtistTopTracks(
@Path("id") id: String,
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
}
}
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.api.endpoints
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/playback-errors`. Reports a
* client-detected playback failure (zero-duration, decode error, ...)
* so the admin inbox surfaces it.
*
* Failures during dispatch are reported via the MutationQueue path
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
*/
interface PlaybackErrorsApi {
@POST("api/playback-errors")
suspend fun report(@Body body: PlaybackErrorReportRequest)
}
/**
* POST body for `/api/playback-errors`. `kind` is one of
* "zero_duration" / "load_failed" / "stalled"; server validates against
* the CHECK constraint enum. `detail` is optional free-text (Media3
* error message, etc.). `clientId` reuses the existing playback
* client identifier the device already sends with `/api/plays/...`
* so support can correlate reports across surfaces.
*/
@kotlinx.serialization.Serializable
data class PlaybackErrorReportRequest(
@kotlinx.serialization.SerialName("track_id") val trackId: String,
val kind: String,
val detail: String? = null,
@kotlinx.serialization.SerialName("client_id") val clientId: String,
)
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?>
@Query(
"SELECT * FROM cached_albums " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortTitle COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -18,6 +18,14 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?>
@Query(
"SELECT * FROM cached_artists " +
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortName COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import kotlinx.coroutines.flow.Flow
@@ -36,4 +37,21 @@ interface CachedLikeDao {
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
)
suspend fun delete(userId: String, entityType: String, entityId: String)
@Query("DELETE FROM cached_likes WHERE userId = :userId")
suspend fun clearForUser(userId: String)
/**
* Atomically replaces the user's entire cached_likes set with
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
* so cross-device unlikes (a row that the server no longer
* surfaces) get removed from the local cache — drift #570
* caught the missing delete pass that left stale Liked tab
* entries pointing at tracks the user had unliked elsewhere.
*/
@Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
clearForUser(userId)
upsertAll(rows)
}
}
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
@Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity?
/**
* Per-playlist count of member tracks resident in the audio cache index.
* LEFT JOINs so playlists with zero cached tracks still appear
* (cachedCount = 0). Drives the offline "fully cached" greying.
*/
@Query(
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
"FROM cached_playlists p " +
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
"GROUP BY p.id",
)
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
@@ -24,6 +24,14 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Query(
"SELECT * FROM cached_tracks " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY title COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -0,0 +1,11 @@
package com.fabledsword.minstrel.cache.db.dao
/**
* Projection: how many of a playlist's member tracks are resident in the audio
* cache index. Backs the offline "fully cached" greying — a playlist is fully
* available offline when [cachedCount] reaches its `trackCount`.
*/
data class PlaylistCachedCount(
val playlistId: String,
val cachedCount: Int,
)
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
* - "rediscover_artists"
* - "most_played_tracks"
* - "last_played_artists"
* - "you_might_like_albums"
* - "you_might_like_artists"
*
* New `section` values need no schema change — the column stores the
* string verbatim and the DAO keys on it.
*
* `entityType` is "album" | "artist" | "track" — dispatches hydration
* to the right per-entity endpoint when a tile is rendered.
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
private const val QUEUED_MESSAGE = "Saved — will sync when online"
/**
* Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks
@@ -21,6 +27,14 @@ object MutationKind {
const val QUARANTINE_FLAG: String = "quarantine_flag"
const val PLAY_OFFLINE: String = "play_offline"
const val REQUEST_CANCEL: String = "request_cancel"
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
// Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on
// background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit).
const val PLAY_ENDED: String = "play_ended"
}
/**
@@ -70,47 +84,59 @@ class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
private val json: Json,
) {
// capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
// tracks while offline) surfaces as one snackbar rather than queueing
// N. replay=0 because a hint observed at enqueue time isn't useful
// to a screen that mounts later.
private val _userEnqueueHints = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
* to surface "Saved — will sync when online" as a snackbar whenever a
* user-driven write hits the offline-fallback path. Background-only
* enqueues (play-events, playback-error reports) do not emit — those
* fire from non-foreground paths where a snackbar would be either
* dropped (no shell mounted) or jarring (lock-screen toggle).
*/
val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()
suspend fun enqueueLikeToggle(
entityType: String,
entityId: String,
desiredState: Boolean,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.LIKE_TOGGLE,
payload = json.encodeToString(
LikeTogglePayload.serializer(),
LikeTogglePayload(entityType, entityId, desiredState),
),
): Long = insertUserDriven(
MutationKind.LIKE_TOGGLE,
json.encodeToString(
LikeTogglePayload.serializer(),
LikeTogglePayload(entityType, entityId, desiredState),
),
)
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CREATE,
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
MutationKind.REQUEST_CREATE,
json.encodeToString(RequestCreatePayload.serializer(), payload),
)
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_UNFLAG,
payload = json.encodeToString(
QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload(trackId),
),
suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
MutationKind.QUARANTINE_UNFLAG,
json.encodeToString(
QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload(trackId),
),
)
suspend fun enqueuePlaylistAppend(
playlistId: String,
trackIds: List<String>,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYLIST_APPEND,
payload = json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload(playlistId, trackIds),
),
): Long = insertUserDriven(
MutationKind.PLAYLIST_APPEND,
json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload(playlistId, trackIds),
),
)
@@ -118,13 +144,19 @@ class MutationQueue @Inject constructor(
trackId: String,
reason: String,
notes: String,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_FLAG,
payload = json.encodeToString(
QuarantineFlagPayload.serializer(),
QuarantineFlagPayload(trackId, reason, notes),
),
): Long = insertUserDriven(
MutationKind.QUARANTINE_FLAG,
json.encodeToString(
QuarantineFlagPayload.serializer(),
QuarantineFlagPayload(trackId, reason, notes),
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
)
@@ -135,15 +167,29 @@ class MutationQueue @Inject constructor(
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CANCEL,
payload = json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
),
)
/**
* Durable close-by-id for an already-open server play row. Background
* path only (no user hint) — same as [enqueuePlayOffline].
*/
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAY_ENDED,
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
),
)
private suspend fun insertUserDriven(kind: String, payload: String): Long {
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
return id
}
}
/**
@@ -195,6 +241,19 @@ data class PlayOfflinePayload(
val source: 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,
)
/**
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
* `POST /api/requests/{id}/cancel` call lost during a connectivity
@@ -203,3 +262,20 @@ data class PlayOfflinePayload(
*/
@Serializable
data class RequestCancelPayload(val requestId: String)
/**
* Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
* `POST /api/playback-errors` call lost during a connectivity hiccup.
* The replayer re-fires the POST with this body. Server is naturally
* idempotent enough — multiple reports of the same (track, user,
* kind) become multiple rows in the admin inbox, which is acceptable
* (the admin can resolve them all with one action). `clientId` is
* the existing playback client identifier from AuthStore.
*/
@Serializable
data class PlaybackErrorReportPayload(
val trackId: String,
val kind: String,
val detail: String? = null,
val clientId: String,
)
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
@@ -5,9 +7,14 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.api.endpoints.RequestsApi
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -17,12 +24,16 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -32,25 +43,24 @@ import javax.inject.Singleton
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
* re-fires each call against the raw Retrofit API (NOT the high-level
* Repository, which would re-enqueue on failure → infinite loop), and
* deletes the row on 2xx success.
* resolves each to an [Outcome]:
* - SENT — server accepted (2xx) → delete the row.
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
* payload) → delete the row so it can't wedge the queue forever.
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
* attempts, retry on the next trigger.
*
* Triggered by:
* - app start (MinstrelApplication @Inject forces the singleton's
* init block to subscribe)
* - every time AuthStore.sessionCookie transitions to a non-null
* value (sign-in, app launch with persisted cookie)
* - app start (MinstrelApplication @Inject forces the singleton's init)
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
* start with a persisted cookie)
* - NetworkStatusController recovering to Healthy (server reachable again
* mid-session — covers offline→online without needing a cold start)
*
* Single in-flight via the `mutex` — back-to-back triggers collapse
* into one drain pass. A proper connectivity-listener / WorkManager
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
* sign-in + app-open triggers cover the common offline → online
* transition (operator unlocks phone, opens app).
*
* Failed rows stay in the queue with their `attempts` and
* `lastAttemptAt` updated. They get another chance on the next
* trigger; no exponential backoff or max-attempts yet — the rows are
* small (low single-digit count for typical use) so retry-forever is
* cheap.
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
* drain pass. No exponential backoff / max-attempts: the rows are few for
* typical use, and permanent failures are now dropped rather than retried
* forever.
*/
@Singleton
class MutationReplayer @Inject constructor(
@@ -58,6 +68,7 @@ class MutationReplayer @Inject constructor(
private val authStore: AuthStore,
private val json: Json,
@ApplicationScope private val scope: CoroutineScope,
networkStatus: NetworkStatusController,
retrofit: Retrofit,
) {
private val likesApi: LikesApi = retrofit.create()
@@ -66,9 +77,12 @@ class MutationReplayer @Inject constructor(
private val playlistsApi: PlaylistsApi = retrofit.create()
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val mutex = Mutex()
private enum class Outcome { SENT, DROP, RETRY }
init {
scope.launch {
authStore.sessionCookie
@@ -76,6 +90,16 @@ class MutationReplayer @Inject constructor(
.distinctUntilChanged()
.collect { drainSafe() }
}
// Server-reachable-again: replay queued writes the moment the
// health signal recovers, so an offline→online transition mid-
// session doesn't wait for the next cold start / re-auth.
scope.launch {
networkStatus.state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.filter { it }
.collect { drainSafe() }
}
}
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
@@ -90,172 +114,185 @@ class MutationReplayer @Inject constructor(
private suspend fun drain() {
val rows = dao.getAll()
// Collapse superseded like-toggles: only the latest desired state per
// (entity) is replayed; older toggles for the same entity are dropped
// unsent. Without this, partial-failure + differential retry could
// replay an older toggle last and invert the final like state.
val superseded = supersededLikeToggleIds(rows)
for (row in rows) {
val sent = runCatching { dispatch(row) }.getOrDefault(false)
if (sent) {
if (row.id in superseded) {
dao.delete(row.id)
} else {
dao.recordAttempt(row.id, Clock.System.now())
continue
}
when (outcomeFor(row)) {
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
}
}
}
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
val latestByEntity = HashMap<String, Long>()
val superseded = HashSet<Long>()
rows.asSequence()
.filter { it.kind == MutationKind.LIKE_TOGGLE }
.forEach { row ->
val decoded = runCatching {
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
}.getOrNull()
if (decoded != null) {
val key = "${decoded.entityType}:${decoded.entityId}"
// `rows` is ascending by id, so a prior entry is always older.
latestByEntity.put(key, row.id)?.let(superseded::add)
}
}
return superseded
}
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
dispatch(row)
} catch (e: HttpException) {
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
} catch (@Suppress("SwallowedException") e: SerializationException) {
// Corrupt / schema-incompatible payload — permanent, drop it.
Outcome.DROP
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
// Transport / unknown — leave queued for the next pass.
Outcome.RETRY
}
/** 4xx are permanent except the two "retry me" statuses. */
private fun isPermanent(code: Int): Boolean =
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
/**
* Returns true if the server accepted the call, false on any
* failure (transport, server error, decode). The replayer treats
* false as "leave the row for next pass" — it doesn't try to
* distinguish permanent rejections from transient ones; a row
* that consistently fails will accumulate attempt count and
* lastAttemptAt for diagnostics.
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified
* by [outcomeFor] — dispatchers no longer swallow.
*/
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
else -> {
// Unknown kind — drop the row by claiming success so a
// stale schema entry can't wedge the queue forever.
true
}
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
private suspend fun dispatchLikeToggle(payload: String): Boolean {
private suspend fun dispatchLikeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) {
LikesRepository.ENTITY_ARTIST -> "artists"
LikesRepository.ENTITY_ALBUM -> "albums"
LikesRepository.ENTITY_TRACK -> "tracks"
else -> return true // unknown variant — drop.
else -> return Outcome.DROP
}
return try {
if (decoded.desiredState) {
likesApi.like(kindPath, decoded.entityId)
} else {
likesApi.unlike(kindPath, decoded.entityId)
}
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
if (decoded.desiredState) {
likesApi.like(kindPath, decoded.entityId)
} else {
likesApi.unlike(kindPath, decoded.entityId)
}
return Outcome.SENT
}
private suspend fun dispatchRequestCreate(payload: String): Boolean {
private suspend fun dispatchRequestCreate(payload: String): Outcome {
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
val body = CreateRequestBody(
kind = decoded.kind,
artistMbid = decoded.artistMbid,
artistName = decoded.artistName,
albumMbid = decoded.albumMbid,
albumTitle = decoded.albumTitle,
trackMbid = decoded.trackMbid,
trackTitle = decoded.trackTitle,
discoverApi.createRequest(
CreateRequestBody(
kind = decoded.kind,
artistMbid = decoded.artistMbid,
artistName = decoded.artistName,
albumMbid = decoded.albumMbid,
albumTitle = decoded.albumTitle,
trackMbid = decoded.trackMbid,
trackTitle = decoded.trackTitle,
),
)
return try {
discoverApi.createRequest(body)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
}
return Outcome.SENT
}
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
return try {
quarantineApi.unflag(decoded.trackId)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: the row stays in the queue with
// attempts + lastAttemptAt incremented; next drain pass
// retries. Per-attempt diagnostic logging lands when we
// wire up Timber at the replayer level.
false
}
quarantineApi.unflag(decoded.trackId)
return Outcome.SENT
}
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
return try {
playlistsApi.appendTracks(
playlistId = decoded.playlistId,
body = AppendTracksRequest(trackIds = decoded.trackIds),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
playlistsApi.appendTracks(
playlistId = decoded.playlistId,
body = AppendTracksRequest(trackIds = decoded.trackIds),
)
return Outcome.SENT
}
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
return try {
quarantineApi.flag(
FlagRequest(
trackId = decoded.trackId,
reason = decoded.reason,
notes = decoded.notes,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
quarantineApi.flag(
FlagRequest(
trackId = decoded.trackId,
reason = decoded.reason,
notes = decoded.notes,
),
)
return Outcome.SENT
}
private suspend fun dispatchPlayOffline(payload: String): Boolean {
private suspend fun dispatchPlayOffline(payload: String): Outcome {
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
return try {
eventsApi.playOffline(
PlayOfflineRequest(
trackId = decoded.trackId,
clientId = decoded.clientId,
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
eventsApi.playOffline(
PlayOfflineRequest(
trackId = decoded.trackId,
clientId = decoded.clientId,
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
),
)
return Outcome.SENT
}
private suspend fun dispatchRequestCancel(payload: String): Boolean {
private suspend fun dispatchPlayEnded(payload: String): Outcome {
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
eventsApi.playEnded(
PlayEndedRequest(
playEventId = decoded.playEventId,
durationPlayedMs = decoded.durationPlayedMs,
),
)
return Outcome.SENT
}
private suspend fun dispatchRequestCancel(payload: String): Outcome {
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
return try {
requestsApi.cancel(decoded.requestId)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
requestsApi.cancel(decoded.requestId)
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
}
}
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.cache.mutations
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to
* ShellScaffold. The queue itself is an app-scoped singleton; this VM
* just bridges its SharedFlow into a `hiltViewModel()`-resolvable
* surface so ShellScaffold can collect it without an EntryPoint
* accessor. Mirrors PlaybackErrorViewModel.
*/
@HiltViewModel
class OfflineWriteHintViewModel @Inject constructor(
mutationQueue: MutationQueue,
) : ViewModel() {
val messages: Flow<String> = mutationQueue.userEnqueueHints
}
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
@@ -16,8 +18,11 @@ import com.fabledsword.minstrel.models.wire.SyncArtistWire
import com.fabledsword.minstrel.models.wire.SyncTrackWire
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
@@ -50,10 +55,15 @@ class SyncController @Inject constructor(
private val trackDao: CachedTrackDao,
private val authStore: AuthStore,
@ApplicationScope private val scope: CoroutineScope,
networkStatus: NetworkStatusController,
retrofit: Retrofit,
) {
private val api: SyncApi = retrofit.create()
// Single in-flight guard: the cookie + health-recovery triggers (and a
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
private val mutex = Mutex()
init {
scope.launch {
authStore.sessionCookie
@@ -61,11 +71,25 @@ class SyncController @Inject constructor(
.distinctUntilChanged()
.collect { syncSafe() }
}
// Re-sync when the server becomes reachable again mid-session, so a
// delta missed while offline lands without waiting for a cold start.
scope.launch {
networkStatus.state
.map { it == ServerHealth.Healthy }
.distinctUntilChanged()
.filter { it }
.collect { syncSafe() }
}
}
/** Public entry-point for "Sync now" affordances. Swallows errors. */
suspend fun syncSafe() {
runCatching { sync() }
if (!mutex.tryLock()) return
try {
runCatching { sync() }
} finally {
mutex.unlock()
}
}
private suspend fun sync() {
@@ -14,11 +14,23 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Single source of truth for the device's "is the internet usable
* right now" signal — wraps [ConnectivityManager] and exposes a hot
* cold-startable Flow that emits `false` while the active network
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier,
* captive portal, etc.) and `true` once a usable network appears.
* Single source of truth for "does the device have a network link at
* all" — wraps [ConnectivityManager] and exposes a hot cold-startable
* Flow that emits `false` only when there is no active INTERNET-capable
* network (airplane mode, no carrier/Wi-Fi) and `true` once any network
* link appears.
*
* Deliberately does NOT require `NET_CAPABILITY_VALIDATED`. VALIDATED
* tracks whether Android reached its own WAN internet-validation probe
* (Google's `generate_204`) — which is the wrong question for a
* self-hosted server that is usually on the LAN. A transient WAN/DNS
* blip (or Android's periodic re-validation) momentarily drops VALIDATED
* while the Minstrel box stays perfectly reachable; gating on it flipped
* the app to Offline with no debounce and fast-failed in-flight playback
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
* authority on whether *Minstrel* is reachable is the `/healthz` poll
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
* has its own failure hysteresis), not this coarse device-link signal.
*
* Used by the shell-level ConnectionErrorBanner; downstream
* repositories can also collect this to gate retry loops.
@@ -35,36 +47,36 @@ class ConnectivityObserver @Inject constructor(
.build()
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
}
override fun onLost(network: Network) {
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
// INTERNET only -- NOT VALIDATED. A WAN/validation flicker
// must not read as "device offline" when the LAN (and the
// Minstrel server on it) is still reachable. /healthz is the
// authority on server reachability.
trySend(
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET,
) &&
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
),
),
)
}
}
cm.registerNetworkCallback(request, callback)
// Seed the initial value so the banner doesn't flash before the
// first capability callback fires.
trySend(hasUsableInternet())
trySend(hasActiveNetwork())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun hasUsableInternet(): Boolean {
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
@@ -0,0 +1,176 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import com.fabledsword.minstrel.update.api.HealthzResponse
import com.fabledsword.minstrel.update.data.VersionResult
import com.fabledsword.minstrel.update.data.isVersionNewer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L
/**
* THE single authority on network/server reachability. Absorbs the former
* VersionCheckController (the /healthz poll + version parsing) and
* ServerHealthController (the tri-state derive) into one signal-driven unit so
* the app has one place that answers "can we reach Minstrel?" — not three
* half-systems reporting differently.
*
* Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner).
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner.
*
* Version compatibility is a byproduct of the same /healthz response.
*
* Constructed at launch via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class NetworkStatusController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
connectivity: ConnectivityObserver,
private val authStore: AuthStore,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val machine = ReachabilityMachine()
private val lastProbeAtMs = AtomicLong(0)
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
private sealed interface Intent {
data class Link(val up: Boolean) : Intent
data class Probe(val resp: HealthzResponse?) : Intent
object OpSuccess : Intent
object OpFailure : Intent
}
private val intents = Channel<Intent>(Channel.UNLIMITED)
init {
scope.launch { reduceLoop() }
scope.launch {
connectivity.online.collect { up ->
intents.trySend(Intent.Link(up))
if (up) probeOnce()
}
}
scope.launch { pollLoop() }
}
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
fun reportSuccess() {
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
}
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
fun reportFailure() {
if (stateInternal.value == ServerHealth.Offline) return
intents.trySend(Intent.OpFailure)
}
/** One-shot recheck for pull-to-refresh and the banner. */
fun recheck() {
scope.launch { probeOnce(force = true) }
}
private suspend fun reduceLoop() {
for (intent in intents) {
val now = System.currentTimeMillis()
when (intent) {
is Intent.Link -> machine.onLinkChange(intent.up)
is Intent.Probe -> applyProbe(intent.resp, now)
Intent.OpSuccess -> machine.onSuccess()
Intent.OpFailure -> {
machine.onOpFailure(now)
// Arbitrate off the reducer thread: awaiting a stalled
// /healthz here would block a concurrent self-proving
// success from snapping us straight back to Healthy.
scope.launch { probeOnce() }
}
}
emit(machine.health())
}
}
private fun applyProbe(resp: HealthzResponse?, now: Long) {
if (resp == null) {
machine.onProbeFailure(now)
} else {
machine.onSuccess()
versionInternal.value = versionResultFor(resp)
}
}
private fun emit(next: ServerHealth) {
if (stateInternal.value != next) {
Timber.w("NetworkStatus -> %s", next)
stateInternal.value = next
}
}
private suspend fun pollLoop() {
probeOnce()
while (true) {
val interval =
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
else POLL_DEGRADED_MS
delay(interval)
probeOnce()
}
}
private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
lastProbeAtMs.set(now)
val resp = runCatching { api.check() }.getOrNull()
intents.trySend(Intent.Probe(resp))
}
private fun versionResultFor(resp: HealthzResponse): VersionResult {
val min = resp.minClientVersion
return when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
/**
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
* composables (TrackRow gating, write-affordance disabling) branch on health
* without re-injecting the controller. Defaults to Healthy so previews/tests
* don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.connectivity
internal const val ESCALATE_AFTER_MS = 120_000L
internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2
/**
* Pure reachability state machine. No Android, no coroutines, no real clock —
* every entry point takes `nowMs`, so it is fully deterministic and unit-
* testable. [NetworkStatusController] wires real time + signals around it.
*
* Reachability (independent of the device link):
* - Reachable last evidence says the server answered.
* - Unstable a probe failed; arbitration/escalation pending.
* - Unreachable corroborated or sustained failure.
*
* [health] folds the device link over that: no link → Offline; otherwise the
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
*
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
* back to Reachable. A failure only escalates when a /healthz probe corroborates
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
* sustained-time backstop.
*/
class ReachabilityMachine {
private enum class Reachability { Reachable, Unstable, Unreachable }
private var linkUp = true
private var reachability = Reachability.Reachable
private var failureStreakStartMs: Long? = null
private val recentOpFailures = ArrayDeque<Long>()
fun onLinkChange(up: Boolean) {
linkUp = up
// Link transitions don't reset reachability — a restored link keeps the
// last-known server reachability until a fresh probe/op result arrives.
if (!up) recentOpFailures.clear()
}
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
fun onSuccess() {
reachability = Reachability.Reachable
failureStreakStartMs = null
recentOpFailures.clear()
}
/** A real network op failed. Ambiguous on its own — records corroboration. */
fun onOpFailure(nowMs: Long) {
pruneOpFailures(nowMs)
recentOpFailures.addLast(nowMs)
}
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
fun onProbeFailure(nowMs: Long) {
pruneOpFailures(nowMs)
if (reachability == Reachability.Reachable) {
reachability = Reachability.Unstable
failureStreakStartMs = nowMs
}
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
reachability = Reachability.Unreachable
}
}
fun health(): ServerHealth = when {
!linkUp -> ServerHealth.Offline
reachability == Reachability.Reachable -> ServerHealth.Healthy
reachability == Reachability.Unstable -> ServerHealth.Unstable
else -> ServerHealth.ServerDown
}
private fun shouldEscalate(nowMs: Long): Boolean {
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
val sustained =
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
return corroborated || sustained
}
private fun pruneOpFailures(nowMs: Long) {
while (recentOpFailures.isNotEmpty() &&
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
) {
recentOpFailures.removeFirst()
}
}
}
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.connectivity
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
import dagger.Lazy
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
private const val HEALTHZ_PATH = "/healthz"
/**
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
* self-proving proof the server is reachable → reportSuccess(); a transport
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
* arbitration.
*
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
* loading must NOT be read as "our server is reachable" — only sentinel-host
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
*
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
* interceptor. By the time a request flows through, the controller singleton is
* already constructed (construct-the-singleton trick in MinstrelApplication).
*/
@Singleton
class ReachabilityReportingInterceptor @Inject constructor(
private val networkStatus: Lazy<NetworkStatusController>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val isMinstrel = request.url.host == PLACEHOLDER_HOST
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
if (!isMinstrel || isHealthz) return chain.proceed(request)
return try {
val response = chain.proceed(request)
if (response.isSuccessful) networkStatus.get().reportSuccess()
response
} catch (e: IOException) {
networkStatus.get().reportFailure()
throw e
}
}
}
@@ -0,0 +1,15 @@
package com.fabledsword.minstrel.connectivity
/**
* The single reachability signal every consumer branches on.
*
* - [Healthy] link up, /healthz ok — normal network behavior.
* - [Unstable] link up, a recent failure with arbitration pending —
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
* anti-flicker intent of commit 5c0db429 while still warning
* the user that something is flaky.
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
* gate to cache-only.
* - [Offline] no device link at all — gate to cache-only.
*/
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
@@ -14,77 +14,121 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L
private const val BACK_ONLINE_FLASH_MS = 2_000L
/**
* Tiny VM that just lifts the [ConnectivityObserver] singleton's
* Flow into a StateFlow with the standard sharing strategy. Keeps
* the banner composable pure-presentation.
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
*/
@HiltViewModel
class ConnectivityBannerViewModel @Inject constructor(
observer: ConnectivityObserver,
networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val online: StateFlow<Boolean> = observer.online.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
initialValue = true,
)
val health: StateFlow<ServerHealth> = networkStatus.state
}
/**
* Banner shown at the top of the shell when the device has no usable
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
* data" copy. Auto-hides via slide+fade when connectivity returns.
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
* window, and a brief "Back online" confirmation when health recovers so
* recovery is unmistakable. Sentence case, understated voice (design system).
*/
@Composable
fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) {
val online by viewModel.online.collectAsStateWithLifecycle()
val health by viewModel.health.collectAsStateWithLifecycle()
var showBackOnline by remember { mutableStateOf(false) }
var wasDown by remember { mutableStateOf(false) }
LaunchedEffect(health) {
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
if (health == ServerHealth.Healthy && wasDown) {
// try/finally so a mid-delay cancellation (health flips again) can't
// orphan the flag and leave "Back online" stuck on screen.
try {
showBackOnline = true
delay(BACK_ONLINE_FLASH_MS)
} finally {
showBackOnline = false
}
}
wasDown = down
}
AnimatedVisibility(
visible = !online,
visible = health != ServerHealth.Healthy || showBackOnline,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "No connection — check Wi-Fi or mobile data.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
BannerContent(health = health, backOnline = showBackOnline)
}
}
@Composable
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
val scheme = MaterialTheme.colorScheme
val background: Color
val foreground: Color
when {
backOnline -> {
background = scheme.secondaryContainer
foreground = scheme.onSecondaryContainer
}
health == ServerHealth.Unstable -> {
background = scheme.surfaceVariant
foreground = scheme.onSurfaceVariant
}
else -> {
background = scheme.errorContainer
foreground = scheme.onErrorContainer
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(background)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
Text(
text = bannerText(health, backOnline),
style = MaterialTheme.typography.bodyMedium,
color = foreground,
)
}
}
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
backOnline -> "Back online."
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
health == ServerHealth.ServerDown ->
"Server unreachable — your cached content is still available."
health == ServerHealth.Unstable -> "Reconnecting…"
else -> ""
}
@@ -39,6 +39,9 @@ import javax.inject.Singleton
* pre-warms the top artists via [HomeArtistPrewarmer].
*/
@Singleton
// Per-section observe accessors (one per Home row) inflate the function
// count past detekt's 11 default — accessor density, not complexity.
@Suppress("TooManyFunctions")
class HomeRepository @Inject constructor(
private val homeIndexDao: CachedHomeIndexDao,
private val metadataProvider: MetadataProvider,
@@ -76,6 +79,12 @@ class HomeRepository @Inject constructor(
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
/**
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
@@ -88,7 +97,11 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -148,5 +161,7 @@ class HomeRepository @Inject constructor(
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
}
}
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -69,7 +70,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -95,11 +96,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades
@@ -117,6 +116,8 @@ data class HomeSections(
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
) {
val isAllEmpty: Boolean
get() = playlists.isEmpty() &&
@@ -124,7 +125,9 @@ data class HomeSections(
rediscoverAlbums.isEmpty() &&
rediscoverArtists.isEmpty() &&
mostPlayedTracks.isEmpty() &&
lastPlayedArtists.isEmpty()
lastPlayedArtists.isEmpty() &&
youMightLikeAlbums.isEmpty() &&
youMightLikeArtists.isEmpty()
}
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -136,7 +139,7 @@ class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -144,9 +147,13 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
/** True when the device has no usable internet — gates the offline-pool cards. */
val offline: StateFlow<Boolean> = connectivity.online
.map { !it }
/**
* Cache-only when there's no link OR the server is unreachable. Reads the
* unified [NetworkStatusController] (not the raw device link) so Home reacts
* to ServerDown too; the transient Unstable state stays calm (not offline).
*/
val offline: StateFlow<Boolean> = networkStatus.state
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -259,37 +266,9 @@ class HomeViewModel @Inject constructor(
*/
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
val detail = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
playlistsRepository.systemShuffle(playlist.systemVariant!!)
} else {
playlistsRepository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
) {
poolMessages.trySend("Couldn't load playlist - check your connection")
return@launch
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
)
return@launch
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
}
// Shared with PlaylistDetailViewModel.play - filters out
// unplayable rows (missing trackId or empty streamUrl) so the
// queue can't end up with tracks Media3 silently rejects.
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
}
val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join()
}
@@ -314,13 +293,14 @@ class HomeViewModel @Inject constructor(
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
/**
* Five home-section flows merged into the sections struct without
* playlists; then merged with the playlists flow in [combineHomeFlows].
* Splitting it like this keeps each combine call inside the
* coroutines built-in arity limit (5) and avoids the typed-vararg
* acrobatics needed for a 6-flow combine across heterogeneous types.
* The core five section flows merged into the sections struct. Split
* out so each combine call stays inside the coroutines built-in arity
* limit (5) and avoids the typed-vararg acrobatics a wider combine
* across heterogeneous types would need. [observeHomeSections] then
* folds in the two you-might-like flows, and [combineHomeFlows] the
* playlists flow.
*/
private fun observeHomeSections() = combine(
private fun observeCoreSections() = combine(
homeRepository.observeRecentlyAddedAlbums(),
homeRepository.observeRediscoverAlbums(),
homeRepository.observeRediscoverArtists(),
@@ -336,6 +316,14 @@ class HomeViewModel @Inject constructor(
)
}
private fun observeHomeSections() = combine(
observeCoreSections(),
homeRepository.observeYouMightLikeAlbums(),
homeRepository.observeYouMightLikeArtists(),
) { core, ymlAlbums, ymlArtists ->
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
}
private fun combineHomeFlows() =
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
@@ -488,6 +476,14 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist,
)
}
youMightLikeSection(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
rediscoverSection(
albums = sections.rediscoverAlbums,
@@ -692,6 +688,61 @@ private fun RediscoverBlock(
}
}
/**
* "You might like" — in-library albums/artists predicted from the user's
* taste that they don't actively spin (server section, daily-built, cold-
* start gated). Sits directly under the system-playlists row. Same
* albums-then-artists block shape as Rediscover; the artist sub-row drops
* its title when an album sub-row precedes it so the two read as one section.
*/
private fun LazyListScope.youMightLikeSection(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
item {
if (albums.isNotEmpty() || artists.isNotEmpty()) {
YouMightLikeBlock(
albums = albums,
artists = artists,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayAlbum = onPlayAlbum,
onPlayArtist = onPlayArtist,
)
} else {
EmptySection(
title = "You might like",
body = "We're still learning your taste — keep listening and " +
"picks you might like will show up here.",
)
}
}
}
@Composable
private fun YouMightLikeBlock(
albums: List<HomeTile<AlbumRef>>,
artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlayAlbum: suspend (String) -> Unit,
onPlayArtist: suspend (String) -> Unit,
) {
Column {
if (albums.isNotEmpty()) {
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
}
if (artists.isNotEmpty()) {
val title = if (albums.isEmpty()) "You might like" else ""
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
}
}
}
@Composable
private fun AlbumsRow(
title: String,
@@ -731,18 +782,19 @@ private fun PlaylistsRow(
icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) },
)
is PlaylistRowItem.Real -> PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
onPlay = { onPlayPlaylist(item.playlist) },
// Match Flutter: refreshable system playlists need
// the live server endpoints, so disable their play
// overlay when offline (user can still tap into the
// detail and shuffle all from cache). User playlists
// play from cache + survive offline.
playEnabled = item.playlist.trackCount > 0 &&
!(offline && item.playlist.refreshable),
)
is PlaylistRowItem.Real -> {
// Greyed offline when the tile needs the live server or
// isn't fully cached — dimmed but still tappable into the
// detail to shuffle whatever subset is cached.
val greyed = offline && item.playlist.unavailableOffline
PlaylistCard(
playlist = item.playlist,
onClick = { onPlaylistClick(item.playlist.id) },
onPlay = { onPlayPlaylist(item.playlist) },
playEnabled = item.playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label,
variant = item.variant,
@@ -758,7 +810,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
}
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
private sealed interface PlaylistRowItem {
internal sealed interface PlaylistRowItem {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
@@ -771,55 +823,81 @@ enum class OfflinePoolKind(val label: String) {
}
/**
* Builds the Home Playlists row. When [offline], the two cache-backed
* pool cards (Recently played, Liked) lead the row. Then For You +
* Discover + 3× Songs-like fixed slots (real card when generated,
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they
* exist — no placeholders for these since they're conditional on
* library shape, not guaranteed singletons. Finally user-owned
* playlists.
* Builds the Home Playlists row.
*
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
* / rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then
* user-owned playlists.
*
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/
private fun buildPlaylistsRow(
internal fun buildPlaylistsRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
offline: Boolean,
): List<PlaylistRowItem> {
if (!offline) return buildOnlineRow(owned, status)
val out = mutableListOf<PlaylistRowItem>(
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
)
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
return out
}
/** The online layout: fixed system slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>()
if (offline) {
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
}
out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
// Secondary system kinds in server-registry order. Only included
// when actually generated — these depend on library shape (Deep
// cuts needs deep albums, On this day needs prior history, etc.)
// so a missing one means "not enough data" rather than "still
// building".
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }
?.let { out += PlaylistRowItem.Real(it) }
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out
}
/**
* Curated real-playlist order (system primaries, then secondary, then user).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first.
*/
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
}
owned.filter { it.systemVariant == null }.forEach { out += it }
return out
}
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building"
s.lastError != null -> "failed"
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
api.getArtistTracks(id).map { it.toDomain() }
/**
* Pulls related artists for the ArtistDetail "Similar artists"
* strip. Network-only — non-critical UI, callers swallow failures
* and render nothing rather than blocking the screen.
*/
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
api.getSimilarArtists(id).map { it.toDomain() }
/**
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
* panel. Network-only; tapping a row plays this exact list as a
* queue, so no persistence is needed.
*/
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
api.getArtistTopTracks(id).map { it.toDomain() }
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "album-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh Success
// refresh (same kind, new detail) doesn't re-fade the whole body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "album-detail") { _ ->
when (val s = state) {
is AlbumDetailUiState.Loading ->
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
is AlbumDetailUiState.Error -> EmptyState(
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
)
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items as lazyItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.TrackRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
navController: NavHostController,
viewModel: ArtistDetailViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -87,8 +96,11 @@ fun ArtistDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Crossfade(targetState = state, label = "artist-detail") { s ->
when (s) {
// Crossfade keyed on `state::class` so a Success → fresh
// Success refresh (same kind, new detail) doesn't re-fade
// the body; only Loading ↔ Success ↔ Error animate.
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
when (val s = state) {
is ArtistDetailUiState.Loading ->
if (s.seed != null) {
SeededArtistLoading(s.seed)
@@ -99,23 +111,35 @@ fun ArtistDetailScreen(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked
.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
artistLiked = artistLiked,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
}
}
}
}
}
@Composable
private fun ArtistSuccessBody(
state: ArtistDetailUiState.Success,
viewModel: ArtistDetailViewModel,
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
ArtistBody(
state = state,
artistLiked = artistLiked,
playingTrackId = playerState.currentTrack?.id,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTopTrackClick = viewModel::playTopTracks,
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
}
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
is ArtistDetailUiState.Success -> state.detail.artist.name
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
@@ -124,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
@Composable
private fun ArtistBody(
detail: ArtistDetailRef,
state: ArtistDetailUiState.Success,
artistLiked: Boolean,
playingTrackId: String?,
onPlay: () -> Unit,
onToggleLike: () -> Unit,
onAlbumClick: (String) -> Unit,
onTopTrackClick: (Int) -> Unit,
onSimilarArtistClick: (String) -> Unit,
) {
val detail = state.detail
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
modifier = Modifier.fillMaxSize(),
@@ -146,6 +174,23 @@ private fun ArtistBody(
onToggleLike = onToggleLike,
)
}
if (state.topTracks.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
TopTracksPanel(
tracks = state.topTracks,
playingTrackId = playingTrackId,
onTrackClick = onTopTrackClick,
)
}
}
if (state.similarArtists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SimilarArtistsStrip(
artists = state.similarArtists,
onArtistClick = onSimilarArtistClick,
)
}
}
if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyAlbumsHint()
@@ -158,6 +203,67 @@ private fun ArtistBody(
}
}
@Composable
private fun TopTracksPanel(
tracks: List<TrackRef>,
playingTrackId: String?,
onTrackClick: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Top tracks",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
tracks.forEachIndexed { index, track ->
TopTrackRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
)
}
}
}
@Composable
private fun TopTrackRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
) {
TrackRow(
title = track.title,
artist = track.artistName,
trackId = track.id,
onClick = onClick,
nowPlaying = nowPlaying,
trailing = {
Text(
text = formatDuration(track.durationSec, zero = "--:--"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
},
)
}
@Composable
private fun SimilarArtistsStrip(
artists: List<ArtistRef>,
onArtistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Similar artists") {
lazyItems(items = artists, key = { it.id }) { artist ->
ArtistCard(
artist = artist,
onClick = { onArtistClick(artist.id) },
)
}
}
}
@Composable
private fun ArtistHeader(
artist: ArtistRef,
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.player.PlayerController
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface ArtistDetailUiState {
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
data class Success(
val detail: ArtistDetailRef,
val similarArtists: List<ArtistRef> = emptyList(),
val topTracks: List<TrackRef> = emptyList(),
) : ArtistDetailUiState
data class Error(val message: String) : ArtistDetailUiState
}
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
loadSecondarySections()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
}
}
/**
* Pulls the non-critical "Similar artists" + "Top tracks" sections
* and folds them into the live Success state. Each failure is
* swallowed (logged only) — these sections render absent rather than
* blocking, so the core artist view never depends on them.
*/
private suspend fun loadSecondarySections() {
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
val current = internal.value
if (current is ArtistDetailUiState.Success) {
internal.value = current.copy(similarArtists = similar, topTracks = top)
}
}
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
try {
block()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
emptyList()
}
/**
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
@@ -118,4 +150,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",
)
}
}
@@ -4,7 +4,6 @@ package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -22,11 +23,10 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -69,8 +69,12 @@ fun LibraryScreen(
navController: NavHostController,
viewModel: LibraryViewModel = hiltViewModel(),
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = LIBRARY_TABS
// HorizontalPager owns the active-tab state — the TabRow reads
// pagerState.currentPage and animateScrollToPage drives it on tap,
// so swipe and tap stay in sync.
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -91,13 +95,13 @@ fun LibraryScreen(
},
)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTab,
selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp,
) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(label) },
)
}
@@ -105,8 +109,11 @@ fun LibraryScreen(
}
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (selectedTab) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner),
) { page ->
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab(
@@ -135,8 +142,13 @@ private fun ArtistsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-artists") { s ->
when (s) {
// Crossfade is keyed on `state::class` (not the whole state)
// so a refresh that emits a fresh UiState.Success — same kind,
// new data — doesn't re-fade the entire grid. Only Loading ↔
// Success ↔ Error ↔ Empty transitions animate; row-level diff
// is owned by LazyVerticalGrid via its item keys.
Crossfade(targetState = state::class, label = "library-artists") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonArtistsGrid()
UiState.Empty -> EmptyState(
title = "No artists yet",
@@ -163,8 +175,8 @@ private fun AlbumsTab(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
Crossfade(targetState = state, label = "library-albums") { s ->
when (s) {
Crossfade(targetState = state::class, label = "library-albums") { _ ->
when (val s = state) {
UiState.Loading -> SkeletonAlbumsGrid()
UiState.Empty -> EmptyState(
title = "No albums yet",
@@ -1,19 +1,23 @@
package com.fabledsword.minstrel.likes.data
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -24,11 +28,14 @@ import javax.inject.Singleton
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
* pattern.
*
* Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`)
* because the AuthController hookup lands with Phase 11. Single-user
* device is the only shape we target; if multi-tenant on one device
* ever shows up, swap this constant for `AuthStore.userId.value`
* everywhere and migrate the cached_likes rows.
* Local `userId` discriminator is the server-side user UUID
* (resolved via [AuthController.currentUser]); falls back to
* [ANONYMOUS_USER_ID] when no user is signed in so existing local
* cache rows from pre-#576 builds remain queryable until the first
* authenticated [refreshIds] call overwrites them. On user-switch
* (sign-out / sign-in as different user) the OUTGOING user's
* cached_likes rows are wiped so they don't leak into the new
* session — drift #576 audit caught the leak.
*
* Write path follows `feedback_offline_first_for_server_writes` —
* never fire-and-forget:
@@ -48,33 +55,78 @@ class LikesRepository @Inject constructor(
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val mutationQueue: MutationQueue,
private val authController: AuthController,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: LikesApi = retrofit.create()
init {
// Drift #576: clear the outgoing user's cached_likes rows
// when the signed-in user changes. Mostly a hygiene fix —
// discriminator-based queries already prevent the wrong
// user from SEEING leaked rows, but without this the rows
// pile up forever as different accounts sign in/out on the
// same device.
scope.launch {
var previousId: String? = null
authController.currentUser.collect { user ->
val newId = user?.id
val prev = previousId
if (prev != null && prev != newId) {
likeDao.clearForUser(prev)
}
previousId = newId
}
}
}
/**
* Server-user-uuid for the current session, or [ANONYMOUS_USER_ID]
* fallback when there's no signed-in user. Used as the
* `cached_likes.userId` discriminator so each account's likes are
* stored under its own bucket.
*/
private fun currentUserId(): String =
authController.currentUser.value?.id ?: ANONYMOUS_USER_ID
// ── Reads ─────────────────────────────────────────────────────────
fun observeLikedArtists(): Flow<List<ArtistRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ARTIST).map { ids ->
ids.mapNotNull { artistDao.getById(it)?.toDomain() }
}
fun observeLikedAlbums(): Flow<List<AlbumRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ALBUM).map { ids ->
ids.mapNotNull { albumDao.getById(it)?.toDomain() }
}
fun observeLikedTracks(): Flow<List<TrackRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids ->
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { ids ->
ids.mapNotNull { trackDao.getById(it)?.toDomain() }
}
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
/**
* Reactive set of liked track ids. Use this when the caller only
* needs "is X in liked set", NOT when it needs to render the liked
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
* against `trackDao` and drops any liked id whose track isn't in
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
* misses cross-device likes whose track row hasn't cached.
*
* Used by playlist/album detail screens to color a row's like
* button independent of whether the track is in the local library.
*/
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet()
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
@@ -85,13 +137,15 @@ class LikesRepository @Inject constructor(
* if it got enqueued for later replay.
*/
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
// 1. Optimistic Room mutation.
// 1. Optimistic Room mutation — scoped to the signed-in user
// (or the legacy "local" fallback for pre-#576 rows).
val uid = currentUserId()
if (desiredState) {
likeDao.upsertAll(
listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)),
listOf(CachedLikeEntity(uid, entityType, entityId)),
)
} else {
likeDao.delete(LOCAL_USER_ID, entityType, entityId)
likeDao.delete(uid, entityType, entityId)
}
// 2. Best-effort REST call; 3. enqueue on failure.
val kindPath = serverPathFor(entityType)
@@ -111,24 +165,38 @@ class LikesRepository @Inject constructor(
}
/**
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
* by the LikedTab ViewModel on init (and by the SyncController in
* Phase 12). Adds rows for IDs the server has but we don't; the
* delete-of-stale-rows pass lives in the SyncController where the
* full reconciliation runs.
* Pulls `GET /api/likes/ids` and atomically replaces the user's
* cached_likes set with the server's canonical view. Called by
* the LikedTab ViewModel on init (and by the SyncController in
* Phase 12).
*
* Drift #570 fix: previously this called `upsertAll` only, which
* added rows for IDs the server had but never removed rows the
* server no longer surfaced — so a cross-device unlike (user
* likes on web, then unlikes on web) left the Liked tab on
* Android showing the now-unliked entry forever. The atomic
* replaceAllForUser DAO method runs a transactional delete-then-
* insert so the local set is exactly what the server reports.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
likeDao.upsertAll(rows)
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
likeDao.replaceAllForUser(uid, rows)
}
companion object {
// TODO(Phase 11): swap for AuthStore.userId.value once auth lands.
const val LOCAL_USER_ID: String = "local"
// Pre-auth fallback discriminator. Drift #576: when no user is
// signed in OR for cached_likes rows written by pre-#576
// builds (which all tagged "local"), reads continue to query
// this bucket so the Liked tab isn't suddenly empty after the
// upgrade. The first authenticated refreshIds() call writes
// rows under the real user UUID; reads then transparently
// switch over via currentUserId().
const val ANONYMOUS_USER_ID: String = "local"
const val ENTITY_ARTIST: String = "artist"
const val ENTITY_ALBUM: String = "album"
const val ENTITY_TRACK: String = "track"
@@ -20,6 +20,8 @@ data class PlaylistRef(
val trackCount: Int = 0,
val coverUrl: String = "",
val ownerUsername: String = "",
/** All member tracks resident in the audio cache — playable fully offline. */
val fullyCached: Boolean = false,
) {
val isSystem: Boolean get() = systemVariant != null
@@ -30,6 +32,13 @@ data class PlaylistRef(
* expose a refresh trigger; rebuilds are scheduler-driven).
*/
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
/**
* Can't be relied on while offline — either it needs the live server to
* (re)generate (a refreshable system mix) or not all its tracks are cached.
* Callers combine this with the current offline state to grey the tile.
*/
val unavailableOffline: Boolean get() = refreshable || !fullyCached
}
/**
@@ -1,5 +1,9 @@
@file:OptIn(ExperimentalSerializationApi::class)
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
@Serializable
data class PlayStartedRequest(
val type: String = "play_started",
// Discriminator MUST stay on the wire. The app's Json has
// encodeDefaults=false, so without this a `type` left at its
// default is omitted entirely and the server 400s the event as
// "unknown event type" — silently dropping every native play.
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
@Serializable
data class PlayEndedRequest(
val type: String = "play_ended",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
@SerialName("play_event_id") val playEventId: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
)
@Serializable
data class PlaySkippedRequest(
val type: String = "play_skipped",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
@SerialName("play_event_id") val playEventId: String,
@SerialName("position_ms") val positionMs: Long,
)
@Serializable
data class PlayOfflineRequest(
val type: String = "play_offline",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val at: String,
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
* `internal/api/types.go HomeIndexPayload`).
*
* Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
* - recentlyAddedAlbums → album
* - rediscoverAlbums → album
* - rediscoverArtists → artist
* - mostPlayedTracks → track
* - lastPlayedArtists → artist
* - youMightLikeAlbums → album
* - youMightLikeArtists → artist
*
* All slices default to empty so missing keys don't throw on decode.
* All slices default to empty so missing keys don't throw on decode
* which also keeps the client safe against older servers that don't
* emit the you-might-like sections yet.
*/
@Serializable
data class HomeIndexWire(
@@ -25,4 +29,6 @@ data class HomeIndexWire(
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
)
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
private data class ReconcileInput(
val queue: List<Pair<String, String>>,
val index: Int,
val window: Int,
val isPlaying: Boolean,
)
init {
scope.launch {
combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow },
) { queue, index, window -> Triple(queue, index, window) }
playerController.uiState.map { it.isPlaying },
) { queue, index, window, isPlaying ->
ReconcileInput(queue, index, window, isPlaying)
}
.distinctUntilChanged()
.collect { (queue, index, window) -> reconcile(queue, index, window) }
.collect { input ->
reconcile(input.queue, input.index, input.window, input.isPlaying)
}
}
}
/**
* Apply the prefetch window to the current queue.
*
* Cancellation of out-of-window jobs always runs -- a queue mutation
* or skip should free bandwidth from stale prefetches immediately.
* Starting new prefetches is gated on [isPlaying]: until the current
* track is actually playing, every byte of upstream bandwidth should
* land on it, not on upcoming-track prefetches. Without this gate a
* cold start fanned out 4-6 concurrent CacheWriter jobs against the
* same OkHttp client as the playback DataSource and the user waited
* ~25 s for the first audio to start; with the gate the current
* track gets the full pipe to its first STATE_READY, then the
* prefetcher fills in the next window.
*/
private suspend fun reconcile(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
isPlaying: Boolean,
) {
mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) {
val targets = computeTargets(queue, index, window)
if (targets.isEmpty()) {
cancelAllLocked()
return
}
// Exclude the currently-playing track (it's loaded by the
// player itself) and walk `window` tracks forward.
val firstIdx = (index + 1).coerceAtMost(queue.size)
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
if (firstIdx > lastIdx) {
cancelAllLocked()
return
}
val targets = queue.subList(firstIdx, lastIdx + 1)
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
// Cancellation always runs so a queue mutation or skip frees
// the pipe immediately, even while paused.
cancelOutOfWindowLocked(targetIds)
if (isPlaying) startInWindowLocked(targets)
}
}
// Cancel jobs for tracks that have slid out of the window.
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
private fun computeTargets(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
): List<Pair<String, String>> {
// Exclude the currently-playing track (it's loaded by the player
// itself) and walk `window` tracks forward.
val firstIdx = index + 1
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
val isValid = index >= 0 && queue.isNotEmpty() && window > 0 && firstIdx <= lastIdx
return if (isValid) queue.subList(firstIdx, lastIdx + 1) else emptyList()
}
// Start prefetches for new arrivals. Skip blank URLs (these
// come from minimal TrackRefs synthesized from playlist rows
// when the upstream track was removed from the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
}
private fun startInWindowLocked(targets: List<Pair<String, String>>) {
// Skip blank URLs (these come from minimal TrackRefs synthesized
// from playlist rows when the upstream track was removed from
// the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs || streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
}
}
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import android.os.Bundle
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* MediaSession callback that adds the like/heart custom command to
* every connecting controller (notification, lock screen, Pixel Watch,
* Android Auto) and routes taps through [LikesRepository.toggleLike]
* so they inherit the offline-resilient MutationQueue path.
*
* The [onConnect] grant is REQUIRED — without it the button is
* silently invisible on some surfaces (Media3 issue #2679). See the
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
*
* Suspend work in [onCustomCommand] is launched on [scope] (a
* service-lifetime scope) so the callback returns synchronously and
* the controller does not block on Room + REST. State updates flow
* back through [LikesRepository.observeIsLiked] so the icon refreshes
* via the like-state job in [MinstrelPlayerService].
*/
class LikeMediaCallback(
private val likes: LikesRepository,
private val scope: CoroutineScope,
) : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(grants)
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val code = if (customCommand.customAction == CMD_TOGGLE_LIKE) {
launchToggleForCurrent(session)
} else {
SessionResult.RESULT_ERROR_NOT_SUPPORTED
}
return Futures.immediateFuture(SessionResult(code))
}
private fun launchToggleForCurrent(session: MediaSession): Int {
val mediaId = session.player.currentMediaItem?.mediaId
?: return SessionResult.RESULT_INFO_SKIPPED
scope.launch {
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
}
return SessionResult.RESULT_SUCCESS
}
companion object {
/** Custom-action key for the like/heart button. Namespaced so
* future custom commands (output picker, etc.) don't collide. */
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
}
}
@@ -0,0 +1,587 @@
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
package com.fabledsword.minstrel.player
import android.os.Handler
import android.os.SystemClock
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import timber.log.Timber
/**
* Integration point for UPnP transport parity. Wraps the local
* ExoPlayer; every transport method either forwards (local route
* active -- the default) or translates into AVTransport SOAP +
* [RemotePlayerState] updates (UPnP route active).
*
* Created inside [MinstrelPlayerService]; runs on the service's main
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
* active, the MediaSession's reads of [Player.isPlaying] and
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
private val holder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val onDrop: (routeName: String) -> Unit,
) : ForwardingPlayer(delegate) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
// Tracks consecutive non-PLAYING poll observations so a single transient
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
// flip the play/pause button. Manual pause still feels instant because it
// bypasses the poll entirely via applyTransportPaused().
@Volatile private var nonPlayingPollStreak = 0
// Wall-clock of the most-recent within-track seek we issued to Sonos.
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
// window reports the *old* position. Without the lockout the scrubber
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
// Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
// catches up to Sonos within RTT rather than the full poll cadence.
// CONFLATED so repeated trySend's between polls don't queue up.
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
// External Player.Listener registry (separate from super.addListener which
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
// no audio loaded while UPnP is active, so it never fires events for our
// synthesized remote state -- the MediaSession's notification card and
// lock-screen scrubber stay frozen on whatever state was last captured
// before UPnP took over. We dual-register: super.addListener keeps the
// listener attached to the delegate (so local-playback events still
// reach it), AND we hold a ref here so we can directly invoke listener
// callbacks on remote-state changes. The listener's read of isPlaying /
// duration / position then routes through our overrides to remoteState.
private val externalListeners = mutableListOf<Player.Listener>()
@Volatile private var lastNotifiedIsPlaying: Boolean = false
@Volatile private var lastNotifiedTrackIdx: Int = -1
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit)
}
}
init {
scope.launch {
holder.active.collect { active -> onActiveChanged(active) }
}
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
// addObserver requires it. The observer just trySend's to the channel.
handler.post {
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
}
}
private fun isRemote(): Boolean = holder.active.value != null
/**
* Returns true when selectUpnp has marked a UPnP route as the intended
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
* window we drop transport commands silently -- they would hit Sonos's
* stale state from a prior session and trigger restarts.
*/
private fun isLoadingUpnp(): Boolean =
holder.target.value != null && holder.active.value == null
// ─── setMediaItems intercepts ──────────────────────────────────────
// When PlayerController.setQueue replaces the queue while Sonos is the
// active route, the wrapped delegate's queue gets the new items but
// Sonos's native queue still holds the OLD tracks -- and the play()
// that PlayerController fires immediately after setMediaItems would
// resume the old Sonos queue (user reported on-device: "player view
// updates but Sonos queue does not"). We clear active + set target
// synchronously here so the next play() in the same IPC sequence
// drops via isLoadingUpnp() = true; the OutputPickerController
// observes the uiState.queue change and runs the resync (re-clears
// Sonos's native queue + AddURIToQueue the new tracks + Play).
override fun setMediaItems(mediaItems: List<MediaItem>) {
super.setMediaItems(mediaItems)
markPendingResyncIfRemote()
}
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
super.setMediaItems(mediaItems, resetPosition)
markPendingResyncIfRemote()
}
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
super.setMediaItems(mediaItems, startIndex, startPositionMs)
markPendingResyncIfRemote()
}
private fun markPendingResyncIfRemote() {
val wasActive = holder.active.value ?: return
Timber.w(
"setMediaItems while UPnP active (%s) -- marking pending resync",
wasActive.routeName,
)
holder.set(null)
holder.setTarget(wasActive.routeId)
}
override fun play() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
if (active == null) {
super.play()
} else {
remoteState.setPlayIntent(true)
scope.launch {
runCatching { retryTransport { active.avTransport.play() } }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
}
}
}
override fun pause() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
if (active == null) {
super.pause()
} else {
remoteState.setPlayIntent(false)
scope.launch {
runCatching { retryTransport { active.avTransport.pause() } }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleTransportFailure(active, it) }
}
}
}
override fun seekTo(positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
if (active == null) {
super.seekTo(positionMs)
} else {
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
remoteState.applyPositionInfo(
positionMs = positionMs,
durationMs = remoteState.durationMs,
trackUri = remoteState.currentTrackUri,
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
.onFailure { handleTransportFailure(active, it) }
}
}
}
/**
* Widget-driven track change (user taps a track in the queue widget).
* Seeks Sonos to the correct queue slot, then seeks within-track if
* [positionMs] is non-zero.
*/
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w(
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
mediaItemIndex, positionMs, active?.routeName,
)
if (active == null) {
super.seekTo(mediaItemIndex, positionMs)
return
}
super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching {
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}.onFailure { handleTransportFailure(active, it) }
}
}
override fun seekToNextMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToNextMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
}
}
override fun seekToPreviousMediaItem() {
if (isLoadingUpnp()) {
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
return
}
val active = holder.active.value
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
if (active == null) {
super.seekToPreviousMediaItem()
return
}
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.previous() } }
.onFailure { handleTransportFailure(active, it) }
}
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
override fun getDuration(): Long =
if (isRemote()) remoteState.durationMs else super.getDuration()
override fun isPlaying(): Boolean =
if (isRemote()) remoteState.isPlaying else super.isPlaying()
override fun getPlaybackState(): Int =
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
// Mirror remote state so any consumer that gates on playWhenReady --
// notably MediaSessionService's foreground-keepalive checks and our own
// onTaskRemoved -- sees the remote renderer as the source of truth.
// Without this, swiping the app away with Sonos playing would stop the
// service, kill the poll loop, and leave Sonos orphaned.
override fun getPlayWhenReady(): Boolean =
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
override fun addListener(listener: Player.Listener) {
super.addListener(listener)
synchronized(externalListeners) { externalListeners.add(listener) }
}
override fun removeListener(listener: Player.Listener) {
super.removeListener(listener)
synchronized(externalListeners) { externalListeners.remove(listener) }
}
/**
* Direct-invoke the externally-registered Player.Listeners so the
* MediaSession's PlaybackState publisher (notification card, lock-screen
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
* The listeners then query isPlaying / getDuration / getCurrentPosition,
* all of which route through to remoteState while UPnP is active.
*
* Posted to the player's application looper because Player.Listener
* callbacks contract on the application thread.
*/
private fun notifyRemoteStateChanged() {
if (!isRemote()) return
val playing = remoteState.isPlaying
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val isPlayingChanged = playing != lastNotifiedIsPlaying
val trackChanged = trackIdx != lastNotifiedTrackIdx
if (!isPlayingChanged && !trackChanged) return
lastNotifiedIsPlaying = playing
lastNotifiedTrackIdx = trackIdx
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
handler.post {
for (l in snapshot) {
if (isPlayingChanged) {
l.onIsPlayingChanged(playing)
l.onPlaybackStateChanged(Player.STATE_READY)
}
if (trackChanged) {
val item = if (trackIdx < delegate.mediaItemCount) {
delegate.getMediaItemAt(trackIdx)
} else {
null
}
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
}
}
}
}
override fun release() {
pollJob?.cancel()
scope.cancel()
handler.post {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
}
super.release()
}
/**
* Run a transport SOAP block, retrying transport-level (IO) failures a
* few times with backoff. A locked phone's WiFi power-save can stall the
* first socket I/O for a second or two; a retry lets the command land once
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
* renderer answered and rejected the action) is NOT retried -- the device
* is alive and retrying won't change its verdict.
*/
private suspend fun <T> retryTransport(block: suspend () -> T): T =
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
/**
* A transport SOAP command failed even after retries. Deliberately does
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
* WiFi power-save can fail one command while the renderer is perfectly
* reachable; dropping on a single command falsely kicked playback back to
* the phone. We log, leave the poll loop running, and nudge an immediate
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
* truly gone, the poll loop trips the drop on its own.
*/
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
if (t is SoapFaultException) {
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
} else {
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
}
pollTrigger.trySend(Unit)
}
private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel()
nonPlayingPollStreak = 0
// Reset notify cache so the first poll after a route flip republishes
// playing/track state to the MediaSession even if it happens to match
// the prior session's values numerically.
lastNotifiedIsPlaying = false
lastNotifiedTrackIdx = -1
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
pollJob = scope.launch { pollLoop(active) }
} else {
remoteState.reset()
}
}
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
private suspend fun pollLoop(active: ActiveUpnp) {
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
val outcome = runCatching { pollOnce(active) }
if (outcome.isSuccess) {
remoteState.recordPollSuccess()
} else if (remoteState.recordPollFailure()) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
return
}
// Race the normal cadence against any external wake (activity
// resume). Whichever wins continues to the next pollOnce.
select<Unit> {
onTimeout(POLL_INTERVAL_MS) {}
pollTrigger.onReceive {}
}
}
}
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
// Inside the seek-ack window, keep the optimistic position we wrote in
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
nonPlayingPollStreak = 0
remoteState.applyTransportPlaying()
}
TransportState.PAUSED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportPaused()
}
}
TransportState.STOPPED -> {
nonPlayingPollStreak += 1
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
remoteState.applyTransportStopped()
}
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
notifyRemoteStateChanged()
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
)
delegate.seekTo(sonosIdx, 0L)
}
}
}
private companion object {
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
// Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only
// kicks in if SOAP fails or Sonos drops the ack entirely.
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
// Transport commands retry transient IO failures so a single WiFi
// power-save stall (locked phone) doesn't abandon the command. 3
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
// worst case at ~7s; a still-failing command then defers to the poll.
const val TRANSPORT_RETRY_ATTEMPTS = 3
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
}
}
/**
* Retry [block] on transient transport-level ([IOException]) failures, with
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
* an [IOException] -- notably [SoapFaultException], where the renderer
* answered and rejected the action -- propagates immediately: the device is
* alive, so retrying won't change its verdict. Extracted from
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
* abandon a single transport command (the bug that falsely reverted Sonos
* playback to local audio).
*/
internal suspend fun <T> retryTransientIo(
attempts: Int,
backoffMs: Long,
block: suspend () -> T,
): T {
var attempt = 0
while (true) {
try {
return block()
} catch (io: IOException) {
attempt += 1
if (attempt >= attempts) throw io
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
delay(backoffMs)
}
}
}
@@ -1,10 +1,30 @@
package com.fabledsword.minstrel.player
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -18,35 +38,150 @@ import javax.inject.Inject
* MediaButtonReceiver is registered automatically by the library, no
* manual receiver class needed.
*
* The session advertises a single custom CommandButton — the like/heart
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
* routes taps through [LikesRepository.toggleLike] so they inherit the
* offline-resilient MutationQueue path. The icon mirrors server state:
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
* rebuilds the preferences list on each emission so a cross-device
* like (web tap) flips the notification heart automatically.
*
* Lifecycle:
* - onCreate: build the ExoPlayer + MediaSession once.
* - onCreate: build the ExoPlayer + MediaSession once, attach the
* callback, set initial preferences, launch the like-state job.
* - onGetSession: return the live session to any binding controller
* (system UI media card, Wear OS companion, MediaController3 clients).
* - onTaskRemoved: if the user swipes the app away, keep playing when
* audio is active (standard media-app behavior — music shouldn't
* die because the app left recents); otherwise stop the service so
* the lingering notification clears.
* - onDestroy: release the session + player.
* - onDestroy: cancel the service scope, then release the session +
* player. Cancellation comes first so the like-state job doesn't
* touch a released session.
*/
@AndroidEntryPoint
class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var playerFactory: PlayerFactory
@Inject lateinit var likesRepository: LikesRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = playerFactory.build()
mediaSession = MediaSession.Builder(this, player).build()
val player: Player = playerFactory.build()
val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback)
.setBitmapLoader(playerFactory.buildBitmapLoader())
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build()
mediaSession = session
serviceScope.launch { observeLikeState(session, player) }
}
/**
* Intent that the media notification / lock-screen card / Pixel
* Watch tile launches when tapped. Carries an extra MainActivity
* consumes to navigate the NavController straight to the full
* NowPlaying screen — without it, Media3 defaults to the launcher
* activity entry point, which lands on whatever shell route
* MainActivity last rendered.
*
* FLAG_ACTIVITY_SINGLE_TOP keeps the existing MainActivity instance
* alive when one is already foregrounded; onNewIntent fires and
* picks up the fresh extra. FLAG_IMMUTABLE is required on
* Android 12+; FLAG_UPDATE_CURRENT keeps the extra in sync if the
* Builder rebuilds the PendingIntent.
*/
private fun buildNowPlayingPendingIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
}
return PendingIntent.getActivity(
this,
REQUEST_OPEN_NOW_PLAYING,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
/**
* Build the like/heart CommandButton. Icon flips between
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
* like state. The session command is the same in both states so
* the callback's onCustomCommand handles them identically (it
* inverts the current observed state regardless of which icon was
* tapped).
*/
private fun buildLikeButton(isLiked: Boolean): CommandButton {
val icon = if (isLiked) {
CommandButton.ICON_HEART_FILLED
} else {
CommandButton.ICON_HEART_UNFILLED
}
return CommandButton.Builder(icon)
.setDisplayName(if (isLiked) "Unlike" else "Like")
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
}
/**
* Collect player.currentMediaItem changes (via a Player.Listener
* lifted into a Flow) and, for each non-null mediaId, observe its
* liked state. On every emission, rebuild the session's media
* button preferences with the icon flipped accordingly.
* flatMapLatest cancels the previous track's subscription so we
* never leak Flows across track transitions.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun observeLikeState(session: MediaSession, player: Player) {
currentMediaIdFlow(player)
.flatMapLatest { mediaId ->
if (mediaId == null) {
flowOf(false)
} else {
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
}
}
.collect { isLiked ->
session.setMediaButtonPreferences(
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
)
}
}
/**
* Lift Player.currentMediaItem changes into a Flow. Emits the
* current mediaId on subscription so the initial icon state is
* correct on the first frame the controller renders (no
* unfilled-then-flips flicker).
*/
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
val listener = object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
trySend(item?.mediaId)
}
}
player.addListener(listener)
awaitClose { player.removeListener(listener) }
}.onStart { emit(player.currentMediaItem?.mediaId) }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
mediaSession
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent)
val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED
// player.isPlaying is overridden on MinstrelForwardingPlayer to return
// remoteState.isPlaying while UPnP is active, so a swipe-away with
// Sonos playing keeps the service (and its UPnP poll loop) alive.
val activelyPlaying = player.isPlaying ||
(player.playWhenReady && player.playbackState != Player.STATE_ENDED)
if (!activelyPlaying) {
stopSelf()
}
@@ -54,6 +189,7 @@ class MinstrelPlayerService : MediaSessionService() {
}
override fun onDestroy() {
serviceScope.cancel()
mediaSession?.run {
player.release()
release()
@@ -61,4 +197,11 @@ class MinstrelPlayerService : MediaSessionService() {
mediaSession = null
super.onDestroy()
}
private companion object {
// Stable request code for the session-activity PendingIntent.
// Arbitrary but distinct from any other PendingIntent we ever
// build against MainActivity.
const val REQUEST_OPEN_NOW_PLAYING = 0x100
}
}
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import java.io.IOException
import java.io.InterruptedIOException
/**
* DataSource wrapper that fails the network read immediately when
* [NetworkStatusController] reports a gating state. CacheDataSource only
* calls this upstream factory on cache misses, so playback of cached audio is
* unaffected -- only "tap a non-cached track while offline" hits this branch
* and gets a fast, meaningful error instead of a multi-second network timeout
* (which then surfaced as a silent decode failure to the user).
*
* Wrapping rather than substituting the OkHttp data source lets the cache
* write path remain intact for when health returns and we DO want to fetch:
* we keep the same upstream all the time, just gate `open()`.
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
private val health: NetworkStatusController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
gateOnHealth()
return try {
val opened = delegate.open(dataSpec)
health.reportSuccess() // bytes flowing from the server == reachable
opened
} catch (e: IOException) {
health.reportFailure() // real network read failed → arbitrate via /healthz
throw e
}
}
/** Fast-fail before touching the network when the server can't be reached. */
private fun gateOnHealth() {
when (health.state.value) {
ServerHealth.Offline -> throw OfflineException(
"Track not in the on-device cache and the device is offline.",
)
ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.",
)
// Unstable is non-gating: still try the network. Healthy too.
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
}
}
override fun close() = delegate.close()
override fun getUri() = delegate.uri
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
delegate.read(buffer, offset, length)
override fun addTransferListener(transferListener: TransferListener) =
delegate.addTransferListener(transferListener)
override fun getResponseHeaders() = delegate.responseHeaders
}
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
private val health: NetworkStatusController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
}
/**
* Signals the audio-source error path that the request was denied because the
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
* .common.PlaybackException] catches it via [InterruptedIOException]'s
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
* through the existing [PlaybackErrorReporter] -> snackbar path.
*/
class OfflineException(message: String) : IOException(message)
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
private var curLastPositionMs: Long = 0
private var curDurationMs: Long = 0
// Whether the currently-tracked play is actively playing (vs paused).
// onStop reads this: a backgrounded play that's still playing is left
// to the live path (the foreground service keeps us alive), while a
// paused one is durably closed so a kill can't orphan its open row.
private var curPlaying: Boolean = false
// Server play_event_id when the live play_started succeeded;
// null if start failed/offline — then the close is captured into
// the offline mutation queue instead of a live ended/skipped.
private var openPlayEventId: String? = null
// True from beginTrack until startLive's play_started resolves. When a
// close races an in-flight start (id still null), the server row is
// being created but we've lost its id — we must NOT also enqueue an
// offline play (that would duplicate); the server auto-closes the
// orphan on the next play_started instead.
private var startInFlight: Boolean = false
private var prevTrackId: String? = null
init {
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
) {
// Track-change → close the prior tracked play.
if (trackId != prevTrackId && curTrackId != null) {
closeCurrent(viaOffline = false)
closeCurrent()
}
// Entered playing for a new track → begin tracking it.
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
if (curTrackId != null && trackId == curTrackId) {
curLastPositionMs = positionMs
if (durationMs > 0) curDurationMs = durationMs
curPlaying = playing
}
prevTrackId = trackId
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
curSource = source
curLastPositionMs = 0
curDurationMs = durationMs
curPlaying = true
openPlayEventId = null
startInFlight = true
startLive(trackId = trackId, source = source)
}
private fun startLive(trackId: String, source: String?) {
val cid = resolveClientId() ?: return
val cid = resolveClientId()
if (cid == null) {
startInFlight = false
return
}
scope.launch {
try {
val response = api.playStarted(
@@ -144,56 +165,72 @@ class PlayEventsReporter @Inject constructor(
) {
// Offline / flaky — openPlayEventId stays null; close
// path will enqueue the completed play for replay.
} finally {
startInFlight = false
}
}
}
private fun closeCurrent(viaOffline: Boolean) {
/**
* Closes the tracked play. Routes by whether the server already has an
* open row, NOT by caller context — this is what keeps background /
* track-change / failure paths from each minting a second row:
* - open server row (id) → close it by id; on failure enqueue a durable
* close-by-id (never an offline play).
* - start in flight (no id yet) → leave it; the server auto-closes the
* orphan on the next play_started. Enqueuing offline here would
* duplicate the in-flight row.
* - no server row at all → record the whole play offline.
*
* Always uses the actual position played; the server's skip rule
* (50%/30s defaults) classifies was_skipped from it.
*/
private fun closeCurrent() {
val trackId = curTrackId
val startedAt = curStartedAt
if (trackId == null || startedAt == null) {
resetCurrent()
return
}
// Always send the actual position played to; the server's
// skip rule (50%/30s defaults, configurable) classifies
// was_skipped from this. If the track ran to completion the
// last observed position is ~= duration, which gives ratio ~1
// and the server records a non-skip. The COMPLETION_TOLERANCE
// rounding the prior shape did is no longer needed — the
// server's threshold is the source of truth.
val durationMs = curLastPositionMs
val source = curSource
val id = openPlayEventId
if (!viaOffline && id != null) {
scope.launch {
try {
api.playEnded(
PlayEndedRequest(
playEventId = id,
durationPlayedMs = durationMs,
),
)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
enqueueOffline(trackId, startedAt, source, durationMs)
}
}
} else {
enqueueOffline(trackId, startedAt, source, durationMs)
val inFlight = startInFlight
when {
id != null -> closeById(id, durationMs)
inFlight -> Unit
else -> enqueueOffline(trackId, startedAt, source, durationMs)
}
resetCurrent()
}
private fun closeById(id: String, durationMs: Long) {
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() {
curTrackId = null
curStartedAt = null
curSource = null
curLastPositionMs = 0
curDurationMs = 0
curPlaying = false
openPlayEventId = null
startInFlight = false
}
private fun enqueueOffline(
@@ -232,8 +269,17 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ────────
override fun onStop(owner: LifecycleOwner) {
if (curTrackId != null) {
closeCurrent(viaOffline = true)
// App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this
// app-scope uiState collector — alive, so the live state machine
// closes the play accurately on the next real track change. Closing
// here (the old always-offline behavior) raced the collector's
// re-begin and minted a duplicate, skip-misclassified row on every
// screen-lock. Only durably close a PAUSED play, where no further
// progress will happen and a process kill would otherwise orphan the
// open server row. See docs/superpowers/2026-06-11 contract audit.
if (curTrackId != null && !curPlaying) {
closeCurrent()
}
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@@ -13,19 +14,23 @@ import javax.inject.Singleton
private const val DEBOUNCE_MS = 2_000L
/**
* Surfaces ExoPlayer track-load failures (404 / decoder failure /
* premature EOS / network drop) as user-visible snackbar messages.
* Surfaces playback failures (load errors + zero-duration tracks) as
* user-visible snackbar messages AND admin-inbox reports.
*
* Without this the player just silently skips the dead track — the
* worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky".
* Without the snackbar the player just silently skips the dead track —
* the worst kind of bug, because the user can't tell "this file is
* broken" from "the app is flaky". Without the admin report the
* operator never finds out the track is bad and the next user hits
* the same wall.
*
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect
* per-error events from [PlayerController.playbackErrorEvents],
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping"
* for a single error or "Skipped N unplayable tracks" when a burst
* lands inside the window. Stops the user from seeing a stack of N
* toasts on a network blip that fails N tracks in a row.
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
* window, emit "Couldn't play 'X' — skipping" for a single error or
* "Skipped N unplayable tracks" when a burst lands inside the window.
*
* The server report fires per event (no debounce) via
* [PlaybackErrorRepository], which handles the offline-first
* MutationQueue fallback per the standing rule for server writes.
*
* ShellScaffold collects [messages] into its existing snackbar host
* so the reporter doesn't need its own UI surface. Constructed at
@@ -35,6 +40,8 @@ private const val DEBOUNCE_MS = 2_000L
@Singleton
class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController,
private val repository: PlaybackErrorRepository,
private val networkStatus: NetworkStatusController,
@ApplicationScope private val scope: CoroutineScope,
) {
private val outChannel = Channel<String>(Channel.BUFFERED)
@@ -46,8 +53,15 @@ class PlaybackErrorReporter @Inject constructor(
scope.launch {
val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { title ->
buffer.add(title)
playerController.playbackErrorEvents.collect { event ->
// A track failing to play is ambiguous (dead server vs. one bad
// file) — let the controller arbitrate via /healthz. No-op when
// already Offline; cheap otherwise.
networkStatus.reportFailure()
// Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws.
scope.launch { repository.report(event) }
buffer.add(event.title)
debounceJob?.cancel()
debounceJob = scope.launch {
delay(DEBOUNCE_MS)
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlaybackErrorReportPayload
import retrofit2.Retrofit
import retrofit2.create
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Writes a [PlaybackErrorEvent] to the server. Follows the standing
* offline-first rule for server writes: try the POST first, fall back
* to the MutationQueue on transport failure so a tracked report is
* never lost.
*
* Reused client_id comes from [AuthStore.clientId] — the same UUID-
* per-install identifier the play-events reporter already sends, so
* support can correlate playback errors with the surrounding plays.
*/
@Singleton
class PlaybackErrorRepository @Inject constructor(
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: PlaybackErrorsApi = retrofit.create()
/**
* Reports a playback failure. Returns [Outcome.ACCEPTED] on a 2xx,
* [Outcome.QUEUED] when the call was buffered to the MutationQueue
* for later replay. Never throws — callers don't need a try block.
*/
suspend fun report(event: PlaybackErrorEvent): Outcome {
val clientId = resolveClientId()
val payload = PlaybackErrorReportPayload(
trackId = event.trackId,
kind = event.kind,
detail = event.detail,
clientId = clientId,
)
return try {
api.report(
PlaybackErrorReportRequest(
trackId = payload.trackId,
kind = payload.kind,
detail = payload.detail,
clientId = payload.clientId,
),
)
Outcome.ACCEPTED
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow — see LikesRepository.toggleLike for
// the same offline-first rationale.
mutationQueue.enqueuePlaybackErrorReport(payload)
Outcome.QUEUED
}
}
/**
* Returns the persisted client id, generating + persisting one on
* first read. Mirrors [PlayEventsReporter.resolveClientId] — the
* value is shared across all client-id-carrying surfaces.
*/
private fun resolveClientId(): String {
authStore.clientId.value?.let { return it }
val fresh = UUID.randomUUID().toString()
authStore.setClientId(fresh)
return fresh
}
enum class Outcome { ACCEPTED, QUEUED }
}
@@ -3,6 +3,10 @@ package com.fabledsword.minstrel.player
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
@@ -18,8 +22,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -57,7 +63,17 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
/**
* UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
* collects this into its snackbar host so a transport / poll failure
* during UPnP playback shows "Disconnected from <name>" to the user.
*/
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -68,13 +84,32 @@ class PlayerController @Inject constructor(
/**
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
* Emits the failing track's title (or `"Track"` when unknown) each
* time Media3's `onPlayerError` fires — not bound to the lifetime of
* any UI subscriber so a torn-down screen doesn't drop events.
* Conflated channel so backpressure can't stall the player loop.
* Fires twice: once when Media3's `onPlayerError` surfaces a load
* failure (decoder, transport, EOS), and once when the player
* reaches STATE_READY with a duration of zero / TIME_UNSET — the
* "track loaded but has no audio" case the user sees as the
* player sitting frozen on a track.
*
* Drift #561: this was originally Channel.CONFLATED, which silently
* dropped every emission except the latest each time the reporter
* loop wasn't actively reading. A network blip that failed 5
* tracks back-to-back would surface only the last failure to the
* snackbar (the "Skipped 5 unplayable tracks" coalescing path was
* dead code) AND only POST one playback_errors row to the admin
* inbox instead of 5. Buffered so every burst event reaches the
* reporter; default capacity is 64 which is well above any real
* burst rate.
*/
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED)
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow()
private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.BUFFERED)
val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()
/**
* Tracks which queue index has already been evaluated for the
* zero-duration / load-failed checks so STATE_READY firing
* repeatedly (after every seek, pause/resume) doesn't re-emit
* the same error event. Reset on each onMediaItemTransition.
*/
private var lastEvaluatedItemIndex: Int = -1
/**
* Stable queue snapshot kept in sync with the player's MediaItems —
@@ -83,17 +118,50 @@ class PlayerController @Inject constructor(
*/
private var queueRefs: List<TrackRef> = emptyList()
/**
* Completes when [mediaController] is non-null and the listener has
* been attached. Used by [awaitReady] so cold-boot callers like
* [ResumeController] can wait for the IPC bind before calling
* transport methods that would otherwise no-op silently. Drift
* #562 caught the race where a fast restore() landed before the
* MediaSessionService connection was up, silently dropping the
* restored queue.
*/
private val readyDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()
init {
scope.launch { connectAndObserve() }
}
/**
* Suspends until the MediaController binding to
* [MinstrelPlayerService] is up and a Player.Listener is attached,
* so a subsequent transport call (setQueue, startRadio, playNext,
* …) will actually reach the player rather than being silently
* swallowed by the `mediaController ?: return` guards.
*/
suspend fun awaitReady(): Unit = readyDeferred.await()
// ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() }
fun pause() { mediaController?.pause() }
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
fun skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
// Each transport call must run on the MediaController's
// applicationLooper; calling from a background coroutine throws
// IllegalStateException (see PlayerController.setQueue's note).
// UI tap handlers are already on Main so the in-place branch hits;
// the background path only fires for cross-thread callers like
// OutputPickerController.selectUpnp (which calls pause() after
// handing playback off to a UPnP renderer).
fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
fun seekTo(positionMs: Long) {
mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
}
fun skipToNext() {
mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
}
fun skipToPrevious() {
mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
}
/** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
fun toggleShuffle() {
@@ -125,18 +193,49 @@ class PlayerController @Inject constructor(
}
/**
* Replace the queue with [tracks] and start playing from [initialIndex].
* Replace the queue with [tracks] starting at [initialIndex].
* [source] tags the queue with its origin (e.g. "for_you") so the
* server-side rotation reporter can advance it; carried in
* MediaItem extras.
*
* [autoplay] controls whether playback starts immediately. The
* default is true to preserve the "user pressed play on a tile"
* UX every existing caller relies on. Drift #560: cold-boot
* resume passes autoplay = false so the persisted queue is
* restored without auto-starting playback after the user has
* been away from the app — starting audio on cold launch was
* surprising for users who had paused mid-track before
* backgrounding.
*/
fun setQueue(tracks: List<TrackRef>, initialIndex: Int = 0, source: String? = null) {
fun setQueue(
tracks: List<TrackRef>,
initialIndex: Int = 0,
source: String? = null,
autoplay: Boolean = true,
) {
val controller = mediaController ?: return
queueRefs = tracks
val items = tracks.map { it.toMediaItem(source) }
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
controller.play()
// Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController
// enforces application-thread access and throws
// IllegalStateException otherwise — post to its applicationLooper
// if we're already there, run directly to avoid the re-dispatch
// latency UI callers depend on.
runOnControllerThread(controller) {
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.prepare()
if (autoplay) controller.play()
}
}
private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
if (Looper.myLooper() == controller.applicationLooper) {
block()
} else {
Handler(controller.applicationLooper).post(block)
}
}
/**
@@ -166,17 +265,92 @@ class PlayerController @Inject constructor(
}
/**
* Seed a fresh radio queue from [trackId] and start playback.
* Replaces the existing queue. The `source` tag is "radio:<id>"
* so the server-side rotation reporter can distinguish radio
* plays from album / playlist plays. No-op on an empty server
* response; throws on transport / non-2xx for the caller to
* surface in a snackbar.
* Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can
* distinguish radio plays from album / playlist plays.
*
* Two modes:
* - Nothing in the queue: start the radio cold from track 0
* (the seed plays first, recommendations follow).
* - Queue already populated: do NOT interrupt — keep the current
* track playing as-is, trim every upcoming track, and append
* the radio results after the current item. If the seed is the
* currently playing track (the common "tap Start Radio on the
* song I'm listening to" case), drop it from the appended list
* so it doesn't immediately repeat. This is an intentional
* divergence from Flutter's `playerActions.startRadio`, which
* always calls `playTracks` and restarts playback from 0.
*
* No-op on an empty server response; throws on transport / non-2xx
* for the caller to surface in a snackbar.
*/
suspend fun startRadio(trackId: String) {
val controller = mediaController ?: return
val tracks = radio.seed(trackId)
if (tracks.isEmpty()) return
setQueue(tracks, initialIndex = 0, source = "radio:$trackId")
val source = "radio:$trackId"
if (controller.mediaItemCount == 0) {
setQueue(tracks, initialIndex = 0, source = source)
} else {
appendRadioToQueue(controller, tracks, trackId, source)
}
}
/**
* Mid-queue radio insert. The current track keeps playing; every
* upcoming item is removed; the radio results are appended after.
* Drops the seed from the appended list when it matches the
* currently-playing track so it doesn't immediately repeat.
*/
private fun appendRadioToQueue(
controller: MediaController,
tracks: List<TrackRef>,
seedTrackId: String,
source: String,
) {
val currentIdx = controller.currentMediaItemIndex
val currentTrack = queueRefs.getOrNull(currentIdx)
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
if (toAppend.isEmpty()) return
val nextIdx = currentIdx + 1
if (nextIdx < controller.mediaItemCount) {
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
}
queueRefs = queueRefs.take(nextIdx) + toAppend
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
}
/**
* When a STATE_READY transition for a freshly-loaded item reports
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
// Skip during UPnP playback (active) AND during the activation load
// window (target set, active not yet wired). ExoPlayer is intentionally
// paused throughout both windows so its STATE_READY duration is always
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
// entire local queue (and spam /api/playback-errors).
val current = queueRefs.getOrNull(idx)
val upnpEngaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
if (current == null || upnpEngaged) return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
kind = "zero_duration",
title = current.title.ifEmpty { "Track" },
detail = "duration=$duration",
),
)
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
} else {
controller.stop()
}
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
@@ -196,16 +370,61 @@ class PlayerController @Inject constructor(
return
}
mediaController = controller
// Drift #562: signal awaitReady() callers (ResumeController et al.)
// that the controller is bound. Listener is attached below in
// the same coroutine so by the time downstream code runs after
// awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller)
// Keep isUpnpLoading current between player-event fires: holder state
// changes (setTarget / set(active)) are independent of Media3 events, so
// onEvents alone would lag behind by up to one event cycle. This collector
// runs for the process lifetime alongside the position poller.
scope.launch {
combine(
activeUpnpHolder.target,
activeUpnpHolder.active,
) { target, active -> target != null && active == null }
.collect { isLoading ->
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
}
}
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val title = queueRefs
.getOrNull(controller.currentMediaItemIndex)
?.title
?.takeIf { it.isNotEmpty() }
?: "Track"
playbackErrorEventsChannel.trySend(title)
val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
val trackId = current?.id ?: return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = trackId,
kind = "load_failed",
title = title,
detail = error.message,
),
)
}
override fun onMediaItemTransition(
mediaItem: androidx.media3.common.MediaItem?,
reason: Int,
) {
// Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1
// The track flipped -- re-anchor the position interpolator
// so the next polling tick treats the new track's
// remoteState.positionMs as fresh rather than carrying the
// old anchor + elapsed forward.
lastSeenRemotePositionMs = -1L
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState != Player.STATE_READY) return
val idx = controller.currentMediaItemIndex
if (idx == lastEvaluatedItemIndex) return
lastEvaluatedItemIndex = idx
handleZeroDurationIfNeeded(controller, idx)
}
override fun onEvents(player: Player, events: Player.Events) {
@@ -215,15 +434,38 @@ class PlayerController @Inject constructor(
?.mediaMetadata
?.extras
?.getString(MINSTREL_SOURCE_KEY)
val isUpnpLoading = activeUpnpHolder.target.value != null &&
activeUpnpHolder.active.value == null
// When UPnP is active, the wrapped ExoPlayer is paused with
// no real audio loaded -- player.duration / isPlaying /
// currentPosition all reflect that. Read from remoteState
// instead so an onEvents fire (e.g. activity resume) doesn't
// clobber the UI with zeros. Duration falls back to the
// wrapped player's value when Sonos hasn't reported one yet
// (pre-first-poll window, or Sonos still buffering) -- the
// wrapped ExoPlayer was prepared with the same MediaItem so
// it knows the real duration before any SOAP poll lands.
val upnpActive = activeUpnpHolder.active.value != null
uiStateInternal.value =
PlayerUiState(
currentTrack = current,
queue = queueRefs,
queueIndex = idx,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
isBuffering = !upnpActive &&
player.playbackState == Player.STATE_BUFFERING,
positionMs = if (upnpActive) {
remoteState.positionMs
} else {
player.currentPosition
}.coerceAtLeast(0),
durationMs = effectiveDuration(
upnpActive,
remoteState.durationMs,
player.duration,
desiredIdx = idx,
controllerIdx = idx,
),
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message,
currentSource = source,
@@ -233,6 +475,7 @@ class PlayerController @Inject constructor(
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
else -> RepeatMode.OFF
},
isUpnpLoading = isUpnpLoading,
)
}
},
@@ -257,15 +500,138 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
if (!controller.isPlaying) continue
uiStateInternal.value = uiStateInternal.value.copy(
positionMs = controller.currentPosition.coerceAtLeast(0),
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
)
tickPositionPoll(controller)
}
}
}
/**
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
interpolatedRemotePosition(effectiveIsPlaying)
} else {
controller.currentPosition
}
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
}
/**
* Event-driven primary path: clear pending the moment Sonos's reported
* Track matches the wrapped player's index. Safety fallback: clear on
* deadline so we don't ignore Sonos's actual state forever if SOAP fails.
*/
private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
remoteState.clearPendingTransport()
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
// sleep can stall it for many seconds). To keep the scrubber moving
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
// between updates we display anchor + elapsed when Sonos is playing.
// A real correction lands as soon as the next poll arrives.
@Volatile private var lastSeenRemotePositionMs: Long = -1L
@Volatile private var positionAnchorMs: Long = 0L
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
val raw = remoteState.positionMs
val now = SystemClock.elapsedRealtime()
if (raw != lastSeenRemotePositionMs) {
lastSeenRemotePositionMs = raw
positionAnchorMs = raw
positionAnchorAtRealtimeMs = now
}
if (!isPlaying) return positionAnchorMs
// Cap how far past the last anchor we extrapolate. After
// MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
// displayed position at anchor + cap rather than projecting wildly.
// The next successful poll re-anchors and motion resumes.
val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
return positionAnchorMs + delta
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`
@@ -301,7 +667,23 @@ class PlayerController @Inject constructor(
.setArtist(artistName)
.setAlbumTitle(albumTitle)
.apply {
// Server-known duration -- gives the lock-screen / notification
// scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source))
// Point the notification / lock-screen art at the SAME album
// cover the in-app surfaces use (TrackRef.coverUrl ->
// /api/albums/{id}/cover). Without this, Media3 falls back to
// whatever art is embedded in the stream's tags, which can be a
// different image than the server's album cover. Setting
// artworkUri here is load-bearing: MediaMetadata.populate()
// overwrites artworkUri + artworkData as a pair, so the
// MediaItem's URI clears any embedded artworkData ExoPlayer
// extracts from the stream -- the cover endpoint wins on both
// surfaces. The session's OkHttp-backed BitmapLoader (see
// PlayerFactory) is what makes this authed placeholder URL load.
if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri())
}
.build()
// Server's stream_url is a relative path (/api/tracks/{id}/stream);
@@ -323,8 +705,40 @@ class PlayerController @Inject constructor(
private fun sourceExtras(source: String): Bundle =
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }
/**
* Duration to surface to the UI. Priority: Sonos's reported duration
* (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
* (only valid once it's probed the stream) -> TrackRef.durationSec
* (always known from the server response). The third tier is what
* keeps the scrubber populated when the user taps Sonos before the
* wrapped player has had time to probe its own duration -- without
* it, both top tiers report 0/TIME_UNSET and the field reads empty
* until the first SOAP poll lands.
*/
@Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
private fun effectiveDuration(
upnpActive: Boolean,
remoteMs: Long,
localMs: Long,
desiredIdx: Int,
controllerIdx: Int,
): Long {
if (upnpActive && remoteMs > 0) return remoteMs
// Tier 2 (wrapped player's probed duration) only valid when the
// wrapped player is on the same track we're trying to show. After a
// Sonos natural advance the polling tick updates desiredIdx from
// Sonos's truth while controllerIdx is briefly stale -- using the
// wrapped player's duration here would surface the old track's
// length under the new track's title.
if (localMs > 0 && controllerIdx == desiredIdx) return localMs
val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
return ref.durationSec.toLong() * MS_PER_SECOND
}
companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
}
}
@@ -334,3 +748,19 @@ class PlayerController @Inject constructor(
// surfaces are driven by Media3's MediaSession callbacks separately;
// they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L
/**
* Structured playback failure event for [PlaybackErrorReporter]. Drives
* both the user-facing snackbar ("Couldn't play X — skipping") and the
* /api/playback-errors admin inbox.
*
* `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
* the server's CHECK constraint. `detail` is free-text (the Media3
* error message, the observed duration value, etc.).
*/
data class PlaybackErrorEvent(
val trackId: String,
val kind: String,
val title: String,
val detail: String? = null,
)
@@ -3,7 +3,10 @@ package com.fabledsword.minstrel.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -11,15 +14,21 @@ import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds the process-singleton ExoPlayer with our shared OkHttp +
* Builds the process-singleton player with our shared OkHttp +
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
* `build()` once during onCreate.
*
@@ -31,12 +40,22 @@ import javax.inject.Singleton
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
* the 2-bucket protection on top by feeding `removeSpan` only for
* unprotected tracks.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
* with capacity=1 so a burst of failures during a single tear-down
* surfaces as one event rather than queueing N.
*/
@Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -46,11 +65,36 @@ class PlayerFactory @Inject constructor(
StandaloneDatabaseProvider(context),
)
fun build(): ExoPlayer {
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
// as one snackbar rather than queueing N.
private val dropEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
delegate = exo,
holder = activeUpnpHolder,
remoteState = remoteState,
onDrop = { name -> emitDrop(name) },
)
}
private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
// Gate network reads on ServerHealth so a cache miss while offline
// fails fast with an OfflineException instead of hitting an OkHttp
// timeout. CacheDataSource only consults the upstream factory on a
// cache miss, so playback of cached audio is unaffected.
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(httpDataSource)
.setUpstreamDataSourceFactory(gatedUpstream)
.setCacheWriteDataSinkFactory(
CacheDataSink.Factory()
.setCache(simpleCache)
@@ -71,4 +115,26 @@ class PlayerFactory @Inject constructor(
.setHandleAudioBecomingNoisy(true)
.build()
}
/**
* BitmapLoader for the MediaSession's notification / lock-screen art.
* Backed by the shared [okHttpClient] so it inherits the same
* BaseUrlInterceptor placeholder rewrite + auth cookie that Coil uses
* for in-app covers — without it the default DefaultHttpDataSource
* loader can't resolve `http://placeholder.invalid/...` and would 401
* on the cover endpoint. Wrapped in CacheBitmapLoader so a cover the
* notification already fetched isn't re-loaded on every metadata
* refresh. Lets the album-cover artworkUri set in
* [PlayerController.toMediaItem] actually render on the media card.
*/
fun buildBitmapLoader(): BitmapLoader =
CacheBitmapLoader(
DataSourceBitmapLoader.Builder(context)
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.build(),
)
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
}
@@ -28,4 +28,6 @@ data class PlayerUiState(
val currentSource: String? = null,
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.OFF,
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
val isUpnpLoading: Boolean = false,
)
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.player
import javax.inject.Inject
import javax.inject.Singleton
/**
* Synthesized state for the UPnP route -- what the ForwardingPlayer
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
* remote leg is active. Not a Player; a container.
*
* Updates flow in from:
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
* - Poll failures -> recordPollFailure()
*
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Any success resets it.
* Failed transport commands no longer drop the route -- they retry and
* otherwise defer to this counter (see MinstrelForwardingPlayer).
*/
@Singleton
class RemotePlayerState @Inject constructor() {
@Volatile var positionMs: Long = 0L; private set
@Volatile var durationMs: Long = 0L; private set
@Volatile var isPlaying: Boolean = false; private set
@Volatile var currentTrackUri: String = ""; private set
@Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
// The operator's last explicit transport intent (play=true, pause=false),
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
// local playback based on THIS, not isPlaying -- so a play() that fails
// its SOAP (stale/dead renderer) still resumes on the phone instead of
// swallowing the tap. Deliberately NOT cleared by applyError.
@Volatile var lastPlayIntent: Boolean = false; private set
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
// give up waiting). When > 0, a user transport action (next/prev/seekTo
// idx) is in flight: ForwardingPlayer has already moved the wrapped
// player's index, but Sonos's reported Track hasn't refreshed via a
// SOAP poll yet. PlayerController.tickPositionPoll skips track
// adjustments while pending is non-zero. Pending clears when:
// (a) [event-driven, primary] a poll lands and Sonos's reported Track
// matches the wrapped player's currentMediaItemIndex; or
// (b) [safety fallback] the deadline expires (covers SOAP-fail cases
// where Sonos never acks).
@Volatile var pendingTransportDeadlineMs: Long = 0L; private set
fun beginPendingTransport(deadlineMs: Long) {
pendingTransportDeadlineMs = deadlineMs
}
fun clearPendingTransport() {
pendingTransportDeadlineMs = 0L
}
@Volatile private var consecutivePollFailures: Int = 0
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
this.positionMs = positionMs
this.durationMs = durationMs
this.currentTrackUri = trackUri
this.trackNumber = trackNumber
}
fun applyTransportPlaying() { isPlaying = true }
fun applyTransportPaused() { isPlaying = false }
fun applyTransportStopped() {
isPlaying = false
positionMs = 0L
}
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
fun applyError(t: Throwable) {
isPlaying = false
lastError = t
}
/** Returns true when the rolling threshold trips this call. */
fun recordPollFailure(): Boolean {
consecutivePollFailures += 1
return consecutivePollFailures >= DROP_THRESHOLD
}
fun recordPollSuccess() { consecutivePollFailures = 0 }
fun reset() {
positionMs = 0L
durationMs = 0L
isPlaying = false
lastPlayIntent = false
currentTrackUri = ""
lastError = null
consecutivePollFailures = 0
trackNumber = 0
pendingTransportDeadlineMs = 0L
}
private companion object {
// ~30 seconds of consecutive poll failures before declaring the route
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
// can stall socket I/O for several seconds without the renderer
// actually being unreachable -- a 3-failure drop kicked us back to
// local audio every time the phone went into a pocket.
const val DROP_THRESHOLD = 30
}
}
@@ -54,12 +54,22 @@ class ResumeController @Inject constructor(
.getOrNull()
?.takeIf { it.tracks.isNotEmpty() }
?: return
// Drift #562: wait for the MediaController binding before
// calling setQueue, otherwise restore() racing the IPC
// handshake silently drops the persisted queue (PlayerController
// setQueue early-returns when mediaController is null).
playerController.awaitReady()
playerController.setQueue(
tracks = payload.tracks,
initialIndex = payload.queueIndex
.coerceAtLeast(0)
.coerceAtMost(payload.tracks.lastIndex),
source = payload.source,
// Drift #560: cold-boot resume must NOT auto-start
// playback. The user has been away from the app; starting
// audio on cold launch is surprising. They tap play to
// resume. Queue + position are restored; transport is idle.
autoplay = false,
)
// PositionMs not seeked yet — Player connection may still be
// establishing; the seek call would no-op. Acceptable to start
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mints stream tokens for a given track id. Pulled into its own Hilt
* singleton so [MinstrelForwardingPlayer] (service-side) and
* [com.fabledsword.minstrel.player.output.OutputPickerController]
* (controller-side) don't each construct their own [CastApi] from
* Retrofit. The shared Retrofit instance is unchanged.
*/
@Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create()
suspend fun mint(trackId: String): StreamTokenResponse =
api.streamToken(StreamTokenRequest(trackId = trackId))
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared singleton handle to the currently-active UPnP route's transport
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path).
*/
data class ActiveUpnp(
val routeId: String,
val routeName: String,
val avTransport: AVTransportClient,
val rendering: RenderingControlClient?,
)
@Singleton
class ActiveUpnpHolder @Inject constructor() {
private val internal = MutableStateFlow<ActiveUpnp?>(null)
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
/**
* Pending route id during selectUpnp's queue-load window. Set when
* loadQueueOnSonos starts; cleared on completion (success or failure).
* ForwardingPlayer overrides treat (target != null && active == null)
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
* silently rather than send them to a half-loaded queue.
*/
private val targetInternal = MutableStateFlow<String?>(null)
val target: StateFlow<String?> = targetInternal.asStateFlow()
fun set(active: ActiveUpnp?) { internal.value = active }
fun setTarget(routeId: String?) { targetInternal.value = routeId }
}
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.player.output
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Bluetooth
import com.composables.icons.lucide.Cast
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Headphones
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Smartphone
import com.composables.icons.lucide.Speaker
/**
* Spotify-style chip showing the current output route. Sits between
* BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open
* the picker sheet.
*
* Visibility rule per the spec: hidden when the route list has
* exactly one entry AND that entry is BuiltIn — no reason to surface
* a picker for "the only thing available." Visibility logic owned
* by the caller (NowPlayingScreen).
*/
@Composable
fun DeviceChip(
route: OutputRoute,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
onClick = onClick,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = iconFor(route.kind),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(CHIP_ICON_DP.dp),
)
Text(
text = route.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Lucide.ChevronDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(CHIP_CHEVRON_DP.dp),
)
}
}
}
internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) {
OutputRoute.Kind.BuiltIn -> Lucide.Smartphone
OutputRoute.Kind.Wired -> Lucide.Headphones
OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth
OutputRoute.Kind.Cast -> Lucide.Cast
OutputRoute.Kind.Other -> Lucide.Speaker
}
private const val CHIP_ICON_DP = 16
private const val CHIP_CHEVRON_DP = 14
@@ -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
}
@@ -0,0 +1,711 @@
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
package com.fabledsword.minstrel.player.output
import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route the
* picker knows about -- MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
* "Phone speaker" pinned first, everything else alphabetical.
*/
data class RouteSnapshot(
val current: OutputRoute,
val available: List<OutputRoute>,
)
/**
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
* Owns the callback lifecycle — default passive behavior at process
* start (route list updates without forcing Bluetooth scans), upgrades
* to active discovery while the picker sheet is open so newly-paired
* devices appear promptly. The [routesState] StateFlow projects the
* current route + sorted available list as a [RouteSnapshot]; the
* picker ViewModel collects it.
*
* Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* reserved for follow-up slices; ignored for now.
*
* Mirrors the OutputPickerController role described in
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
* and the UPnP extensions in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
*/
@Singleton
class OutputPickerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
) {
private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
.build()
/**
* Internal projection of the live MediaRouter snapshot. Refreshed
* on every Callback event; combined downstream with UPnP routes
* into the public [routesState].
*/
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
private val selectUpnpMutex = Mutex()
val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal,
upnpDiscovery.routes,
upnpDiscovery.sonosTopology,
selectedUpnpRouteIdInternal,
) { sys, upnp, _, upnpSelected ->
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
val visibleUpnp = upnp
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
.map { OutputRoute.fromUpnpRoute(it) }
val merged = sys.available + visibleUpnp
val current = if (upnpSelected != null) {
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
} else {
sys.current
}
RouteSnapshot(current = current, available = sortRoutes(merged))
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteSelected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
override fun onRouteUnselected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
}
// Last-synced queue identity. Used by observeQueueChangesForSonosResync
// to detect when the user has mutated the queue (full replacement,
// playNext insert, or radio-append) while UPnP is active and apply the
// minimum-incremental set of Sonos SOAP operations to bring its native
// queue back in sync. Stored as the full id list (not a join-key) so we
// can run the longest-common-prefix / common-suffix diff.
private var lastSyncedQueueIds: List<String>? = null
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
// non-playing. Cancelled on resume, route change, or explicit disconnect.
// See idleRevertAction for the arm/cancel decision.
private var idleRevertJob: Job? = null
init {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
// route-list updates flow through onRouteAdded/Removed/Changed
// without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (the poll loop's consecutive-failure threshold tripping --
// the sole drop arbiter; a failed transport command no longer drops),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
scope.launch {
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeIdleRevertWhileUpnp() }
}
/**
* 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)
}
/**
* When the user plays a different playlist while Sonos is active,
* PlayerController.setQueue replaces the local queue but Sonos's
* native queue still holds the OLD tracks. MinstrelForwardingPlayer's
* setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs
* loadQueueOnSonos to push the new tracks to Sonos.
*
* Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route
* selection), so this collector doesn't fire during that window. Only
* a fresh setQueue from PlayerController bumps the joined-ids key.
*/
private suspend fun observeQueueChangesForSonosResync() {
playerController.uiState.collect { state ->
val newIds = state.queue.map { it.id }
val oldIds = lastSyncedQueueIds
if (newIds == oldIds) return@collect
lastSyncedQueueIds = newIds
// Route can be in target (setMediaItems-induced clearing already
// ran in ForwardingPlayer) OR in active (queue changed via
// addMediaItem / removeMediaItems etc. which don't hit the
// markPending hook).
val routeId = activeUpnpHolder.target.value
?: activeUpnpHolder.active.value?.routeId
?: return@collect
if (state.queue.isEmpty()) {
Timber.w("Sonos resync skipped: empty queue (clearing target)")
activeUpnpHolder.setTarget(null)
return@collect
}
scope.launch {
resyncSonosQueue(routeId, oldIds.orEmpty(), state.queue, state.queueIndex)
}
}
}
/**
* Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
* + AddURIToQueue at the insertion point) so playback continues without
* interruption -- that's what playNext / radio-append want. Falls back to
* the full removeAllTracks + reload path when the diff implies the current
* Sonos track was deleted (e.g. user switched playlists), which is what
* the user-reported "Sonos queue does not update" bug needed.
*/
private suspend fun resyncSonosQueue(
routeId: String,
oldIds: List<String>,
newQueue: List<TrackRef>,
newCurrentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w(
"Sonos resync: route or transport gone for %s, dropping to local",
routeId,
)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
return@withLock
}
val handledIncrementally = runCatching {
tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
}
if (handledIncrementally) {
// Active was never cleared on the incremental path; clear any
// target that markPendingResyncIfRemote set (it didn't, for
// incremental cases that don't go through setMediaItems, but
// belt-and-suspenders).
activeUpnpHolder.setTarget(null)
return@withLock
}
// Full rebuild: ensure active is cleared so transport calls drop
// (markPendingResyncIfRemote may already have done this on the
// setMediaItems path).
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(routeId)
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching {
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = routeId,
routeName = outputRoute.name,
avTransport = transport,
rendering = rendering,
),
)
activeUpnpHolder.setTarget(null)
}.onFailure { e ->
Timber.w(e, "Sonos resync (full) failed -- dropping to local")
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
* consecutive-failure threshold tripping). Captures the last remote
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
// Resume on the operator's last play-intent, not the observed remote
// state: after a pause, isPlaying is false, but a failed play() that
// triggered this drop means the operator DID ask to play -- honor it.
val wasPlayingRemote = remoteState.lastPlayIntent
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
/**
* Upgrade callback registration to active discovery — call when
* the picker sheet opens so newly-paired Bluetooth devices
* surface within a few seconds, and fire an SSDP M-SEARCH burst
* for UPnP renderers. Idempotent: re-registering with a
* new flag set replaces the prior registration in MediaRouter.
*/
fun upgradeDiscovery() {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
upnpDiscovery.upgradeDiscovery()
}
/**
* Downgrade callback registration back to default passive behavior
* — call when the picker sheet closes so we don't keep Bluetooth
* scanning on for battery cost. Two-arg overload = no flag =
* passive. Same idempotent re-registration semantics. The UPnP
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
* always on); the call is preserved for API parity.
*/
fun downgradeDiscovery() {
mediaRouter.addCallback(selector, callback)
upnpDiscovery.downgradeDiscovery()
}
/**
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
* routes mint a signed stream token + drive AVTransport on the
* discovered renderer + pause local playback. Other protocols
* (CAST / SONOS) are reserved for follow-up slices and are
* silently ignored — the picker shouldn't show them yet.
*/
fun select(route: OutputRoute) {
when (route.protocol) {
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
}
}
private fun selectSystem(route: OutputRoute) {
val wasUpnp = selectedUpnpRouteIdInternal.value
if (wasUpnp != null) {
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
scope.launch {
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
}
}
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target)
}
/**
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly.
*
* Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress.
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* are never routed to a half-loaded Sonos queue.
*/
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val uiState = playerController.uiState.value
val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return@withLock
}
// Honor Sonos topology: pick the coordinator's route when the user
// tapped a group row. Suppression in routesState already keeps the
// visible row at the coordinator's id, so this is identity in the
// common case -- defensive for follow-up flows.
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
if (transport == null) {
Timber.w(
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
"(route disappeared or id mismatch with discovery list)",
)
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos.
playerController.pause()
selectedUpnpRouteIdInternal.value = effectiveRoute.id
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
// silently while target is set but active is null -- the user's premature
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
routeId = effectiveRoute.id,
routeName = effectiveRoute.name,
avTransport = transport,
rendering = rendering,
),
)
// Selecting a route loads the queue and Plays on the renderer, so
// the standing intent is "play" -- a later drop should resume local.
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
.firstOrNull { it.id == routeId }
?.renderingControlUrl ?: return null
return RenderingControlClient(SoapClient(okHttp), rcUrl)
}
private fun refresh() {
systemRoutesInternal.value = snapshotFromRouter()
}
private fun snapshotFromRouter(): RouteSnapshot {
val all = mediaRouter.routes
.filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(all))
}
/**
* BuiltIn "Phone speaker" pinned first; everything else
* alphabetical. Selection state is conveyed by the radio button
* indicator in the picker row, not by sort order.
*/
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
return builtIn + rest.sortedBy { it.name.lowercase() }
}
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
const val IDLE_REVERT_MS = 5 * 60 * 1000L
}
}
@@ -0,0 +1,211 @@
package com.fabledsword.minstrel.player.output
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Circle
import com.composables.icons.lucide.CircleCheck
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.WifiOff
/**
* Material 3 ModalBottomSheet listing the available output routes.
* Tap a row to select + dismiss. Selected route shows the accent
* CircleCheck; others show an empty Circle. Long route names
* truncate cleanly via maxLines = 2 + Ellipsis.
*
* Two footer hints, each driven by a flag the host screen owns:
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
* request flow and sets this true on denial so the user sees the
* "pair in Settings" affordance.
* - [noUpnpDiscovered] — set true when the picker has been open for
* a few seconds and no UPnP renderers have arrived; surfaces the
* "router may be blocking multicast" hint. Defaults to false so
* existing call sites that haven't wired the discovery-timing
* logic continue to render without the hint.
*
* Keeping the hint flags external preserves this composable's
* focus-on-rendering shape.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutputPickerSheet(
snapshot: RouteSnapshot,
permissionDenied: Boolean,
onRouteSelected: (OutputRoute) -> Unit,
onDismiss: () -> Unit,
noUpnpDiscovered: Boolean = false,
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Output",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 8.dp),
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(items = snapshot.available, key = { it.id }) { route ->
RouteRow(
route = route,
isSelected = route.id == snapshot.current.id,
onClick = { onRouteSelected(route) },
)
}
}
if (permissionDenied) {
PermissionHintRow()
}
if (noUpnpDiscovered) {
MulticastHintRow()
}
}
}
}
@Composable
private fun RouteRow(
route: OutputRoute,
isSelected: Boolean,
onClick: () -> Unit,
) {
val tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = iconFor(route.kind),
contentDescription = null,
tint = tint,
modifier = Modifier.size(ROW_ICON_DP.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = route.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subtitle = route.description ?: defaultSubtitle(route)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle,
contentDescription = if (isSelected) "Selected" else "Not selected",
tint = tint,
modifier = Modifier.size(ROW_ICON_DP.dp),
)
}
}
}
@Composable
private fun PermissionHintRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(HINT_ICON_DP.dp),
)
Text(
text = "Pair a Bluetooth device in Settings to see it here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun MulticastHintRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.WifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(HINT_ICON_DP.dp),
)
Text(
text = "Your router may be blocking multicast discovery.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available"
}
private const val ROW_ICON_DP = 24
private const val HINT_ICON_DP = 20
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.player.output
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* NowPlaying-scoped projection over [OutputPickerController]. The
* controller is the long-lived Hilt singleton (its MediaRouter
* callback must not churn with screen lifecycle); this ViewModel is
* a thin lens over its [OutputPickerController.routesState] Flow
* plus the sheet's visibility state.
*
* Sheet open/close are forwarded to the controller's discovery
* toggle so active MediaRouter discovery only runs while the sheet
* is actually visible.
*/
@HiltViewModel
class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
initialValue = controller.routesState.value,
)
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
fun onChipTapped() {
sheetVisibleInternal.value = true
controller.upgradeDiscovery()
}
fun onSheetDismissed() {
sheetVisibleInternal.value = false
controller.downgradeDiscovery()
}
fun onRouteSelected(route: OutputRoute) {
controller.select(route)
sheetVisibleInternal.value = false
controller.downgradeDiscovery()
}
private companion object {
// SharingStarted timeout so quick screen-orientation changes
// don't tear down + re-subscribe the controller's Flow.
const val STOP_TIMEOUT_MS = 5_000L
}
}
@@ -0,0 +1,103 @@
package com.fabledsword.minstrel.player.output
import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
/**
* Narrow domain model for an audio output route. Independent of
* [MediaRouter.RouteInfo] so the picker UI can preview without an
* Android framework presence (RouteInfo can't be constructed in
* JVM tests, mirroring the LikeMediaCallback constraint).
*
* The [protocol] field is a forward-compatibility hook for the
* UPnP / DLNA / Sonos / Cast slice (scope captured in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
* THIS slice always sets `protocol = SYSTEM` — the system-route
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
*/
data class OutputRoute(
val id: String,
val name: String,
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
enum class Protocol {
/** System-managed routes — built-in speaker, wired, Bluetooth. */
SYSTEM,
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
UPNP,
/** Chromecast via the Cast SDK — reserved for a later slice. */
CAST,
/** Sonos via the Sonos extension on top of UPnP — reserved. */
SONOS,
}
companion object {
/**
* Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
-> Kind.Wired
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other
}
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute(
id = route.id,
name = route.name,
description = route.description,
kind = kind,
protocol = Protocol.SYSTEM,
isConnected = connected,
)
}
/**
* Lift a discovered UPnP renderer into the picker's domain
* model. Used by the UPnP discovery controller to merge
* network speakers into the same `OutputPickerController`
* routes stream the system routes come through.
*
* `isConnected = false` because UPnP devices have no
* MediaRouter connection-state concept — they're always
* "available" on the LAN, and the picker's selected-route
* rendering handles the "currently playing" indicator.
*
* Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields
* are blank.
*/
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
val description = listOfNotNull(
route.manufacturer.takeIf { it.isNotBlank() },
route.modelName.takeIf { it.isNotBlank() },
).joinToString(" ").ifBlank { "Network speaker" }
return OutputRoute(
id = route.id,
name = route.name,
description = description,
kind = Kind.Other,
protocol = Protocol.UPNP,
isConnected = false,
)
}
}
}
@@ -0,0 +1,296 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"CurrentURI" to uri,
"CurrentURIMetaData" to metadata,
),
)
}
/**
* Convenience overload that builds DIDL-Lite metadata around [uri]
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
* rejects empty DIDL with vendor error 1023; this constructs the
* minimal-but-Sonos-acceptable shape:
* <DIDL-Lite>
* <item id="0" parentID="-1" restricted="1">
* <dc:title>...</dc:title>
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
}
/**
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
* leftover queue from prior sessions before loading our local queue.
*/
suspend fun removeAllTracksFromQueue() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "RemoveAllTracksFromQueue",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Remove a contiguous range of tracks from the renderer's native queue.
* Sonos-specific extension to AVTransport; `UpdateID=0` skips the queue-
* version check so this works without first calling GetQueue to learn
* the current update id.
*
* [startingIndex] is 1-based per Sonos convention; [numberOfTracks] is
* the count to remove. Used by OutputPickerController's incremental
* queue resync path (radio-append: remove tail, then AddURIToQueue
* the new items).
*/
suspend fun removeTrackRangeFromQueue(startingIndex: Int, numberOfTracks: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "RemoveTrackRangeFromQueue",
args = mapOf(
"InstanceID" to "0",
"UpdateID" to "0",
"StartingIndex" to startingIndex.toString(),
"NumberOfTracks" to numberOfTracks.toString(),
),
)
}
/**
* Append a track to the renderer's queue. Sonos returns the assigned
* track number + new total queue length in the response, but we don't
* read those (we know our intended position). DIDL-Lite metadata is
* required; reuses the same shape as [setAVTransportURIWithMetadata].
*
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
* Our caller passes 1, 2, 3, ... to ensure stable order.
*/
suspend fun addURIToQueue(
uri: String,
mime: String,
title: String,
enqueuedURIPosition: Int = 0,
) {
val safeTitle = title.ifBlank { "Minstrel" }
val didl = buildDidlLite(uri, mime, safeTitle)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "AddURIToQueue",
args = mapOf(
"InstanceID" to "0",
"EnqueuedURI" to uri,
"EnqueuedURIMetaData" to didl,
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
"EnqueueAsNext" to "0",
),
)
}
/** Skip to the next track in the renderer's queue. */
suspend fun next() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Next",
args = mapOf("InstanceID" to "0"),
)
}
/** Skip to the previous track in the renderer's queue. */
suspend fun previous() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Previous",
args = mapOf("InstanceID" to "0"),
)
}
/**
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
* target is the 1-based track index. Separate from the existing
* [seek] which uses unit REL_TIME for position-within-track.
*/
suspend fun seekToTrack(trackNumber: Int) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "TRACK_NR",
"Target" to trackNumber.toString(),
),
)
}
suspend fun play() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Play",
args = mapOf("InstanceID" to "0", "Speed" to "1"),
)
}
suspend fun pause() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Pause",
args = mapOf("InstanceID" to "0"),
)
}
suspend fun stop() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Stop",
args = mapOf("InstanceID" to "0"),
)
}
suspend fun seek(positionMs: Long) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Seek",
args = mapOf(
"InstanceID" to "0",
"Unit" to "REL_TIME",
"Target" to formatHhMmSs(positionMs),
),
)
}
suspend fun getPositionInfo(): PositionInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetPositionInfo",
args = mapOf("InstanceID" to "0"),
)
return PositionInfo(
track = result["Track"]?.toIntOrNull() ?: 0,
trackUri = result["TrackURI"].orEmpty(),
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
)
}
suspend fun getTransportInfo(): TransportInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetTransportInfo",
args = mapOf("InstanceID" to "0"),
)
val state = when (result["CurrentTransportState"]) {
"PLAYING" -> TransportState.PLAYING
"PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING
else -> TransportState.UNKNOWN
}
return TransportInfo(state)
}
private fun buildDidlLite(uri: String, mime: String, title: String): String {
// Sonos requires (a) the rinconnetworks namespace declared on
// <DIDL-Lite> even if we don't use Rincon elements directly, and
// (b) a <desc id="cdudn"> element identifying the URI as an
// external (non-Sonos-library) source. Without those, Sonos
// discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title
// (logcat 2026-06-04 confirmed). Match SoCo's pattern.
val safeTitle = title.ifBlank { "Minstrel" }
return buildString {
append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
append("</item>")
append("</DIDL-Lite>")
}
}
private fun formatHhMmSs(positionMs: Long): String {
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
val h = totalSec / SECONDS_PER_HOUR
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
val s = totalSec % SECONDS_PER_MINUTE
return "%d:%02d:%02d".format(h, m, s)
}
private fun parseHhMmSs(raw: String): Long {
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
return if (parts.size == EXPECTED_HMS_PARTS) {
val (h, m, s) = parts
((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
} else {
0L
}
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L
const val EXPECTED_HMS_PARTS = 3
}
}
data class PositionInfo(
val track: Int,
val trackUri: String,
val relTimeMs: Long,
val trackDurationMs: Long,
)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
data class TransportInfo(val state: TransportState)
@@ -0,0 +1,128 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* Pull-parsed UPnP device description (the XML returned from a
* discovered LOCATION URL). Carries the bits we actually need for
* the picker: friendlyName / manufacturer / modelName for display,
* AVTransport + RenderingControl control URLs for command dispatch.
*
* Filtered to MediaRenderer-capable devices — anything without an
* AVTransport service control URL is dropped by [parse] returning
* null (we can't make it play).
*/
data class DeviceDescription(
val udn: String,
val friendlyName: String,
val manufacturer: String,
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
) {
companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
private const val TAG_SERVICE = "service"
private const val TAG_UDN = "UDN"
private const val TAG_FRIENDLY_NAME = "friendlyName"
private const val TAG_MANUFACTURER = "manufacturer"
private const val TAG_MODEL_NAME = "modelName"
private const val TAG_SERVICE_TYPE = "serviceType"
private const val TAG_CONTROL_URL = "controlURL"
/**
* Parse [xml] (the body fetched from the SSDP LOCATION URL),
* resolving relative service control URLs against [base].
* Returns null when AVTransport is missing — we have no way
* to control the device without it.
*/
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val acc = ParseState()
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
handleEvent(parser, acc, base)
parser.next()
}
val avt = acc.avtControlUrl ?: return null
return DeviceDescription(
udn = acc.udn,
friendlyName = acc.friendlyName,
manufacturer = acc.manufacturer,
modelName = acc.modelName,
avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl,
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
)
}
private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
when (parser.eventType) {
XmlPullParser.START_TAG -> handleStartTag(parser, acc)
XmlPullParser.END_TAG -> handleEndTag(parser, acc, base)
else -> Unit
}
}
private fun handleStartTag(parser: XmlPullParser, acc: ParseState) {
when (parser.name) {
TAG_SERVICE -> {
acc.inService = true
acc.serviceType = ""
acc.serviceControlUrl = ""
}
TAG_UDN -> acc.udn = parser.nextTextSafe()
TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe()
TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe()
TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe()
TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe()
TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe()
}
}
private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
if (parser.name != TAG_SERVICE) return
val resolved = resolveControlUrl(base, acc.serviceControlUrl)
when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
}
acc.inService = false
}
private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? {
if (path.isBlank()) return null
return path.toHttpUrlOrNull() ?: base.resolve(path)
}
private fun XmlPullParser.nextTextSafe(): String =
runCatching { nextText() }.getOrDefault("")
}
/**
* Mutable accumulator used during pull-parsing. Lives only for the
* duration of one [parse] call.
*/
private class ParseState {
var udn: String = ""
var friendlyName: String = ""
var manufacturer: String = ""
var modelName: String = ""
var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null
var zgtControlUrl: HttpUrl? = null
var inService: Boolean = false
var serviceType: String = ""
var serviceControlUrl: String = ""
}
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* RenderingControl service wrapper for hardware-volume routing while a
* UPnP route is active. GetVolume seeds an in-memory cache; SetVolume
* is invoked by NowPlayingScreen's volume-key interceptor. Clamps to
* the UPnP-standard 0..100 range.
*/
class RenderingControlClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getVolume(channel: String = "Master"): Int {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetVolume",
args = mapOf("InstanceID" to "0", "Channel" to channel),
)
return args["CurrentVolume"]?.toIntOrNull() ?: 0
}
suspend fun setVolume(volume: Int, channel: String = "Master") {
val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetVolume",
args = mapOf(
"InstanceID" to "0",
"Channel" to channel,
"DesiredVolume" to clamped.toString(),
),
)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
const val VOLUME_MIN = 0
const val VOLUME_MAX = 100
}
}
@@ -0,0 +1,230 @@
package com.fabledsword.minstrel.player.output.upnp
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
* pulled in via jupnp; we control every line and integrate cleanly
* with the app's OkHttpClient (shared connection pool, timeouts).
*
* Builds the standard SOAP 1.1 envelope, POSTs with the required
* SOAPACTION + Content-Type headers, returns the parsed
* `<action>Response` element as a Map<String, String> (UPnP responses
* are flat string maps).
*
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
* device's way of saying "I rejected your request"). Other transport
* errors propagate as IOException.
*/
class SoapClient(
private val okHttp: OkHttpClient,
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) {
suspend fun call(
controlUrl: HttpUrl,
serviceType: String,
action: String,
args: Map<String, String> = emptyMap(),
): Map<String, String> = withContext(Dispatchers.IO) {
val envelope = buildEnvelope(serviceType, action, args)
val request = Request.Builder()
.url(controlUrl)
.post(envelope.toRequestBody(null))
.header("Content-Type", SOAP_CONTENT_TYPE)
.header("SOAPACTION", "\"$serviceType#$action\"")
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
onRawResponse?.invoke(action, body)
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
parseResponseArgs(body, action)
}
}
private fun buildEnvelope(
serviceType: String,
action: String,
args: Map<String, String>,
): String = buildString {
append("<?xml version=\"1.0\"?>")
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
append("<s:Body>")
append("<u:").append(action)
append(" xmlns:u=\"").append(serviceType).append("\">")
args.forEach { (k, v) ->
append("<").append(k).append(">")
append(xmlEscape(v))
append("</").append(k).append(">")
}
append("</u:").append(action).append(">")
append("</s:Body>")
append("</s:Envelope>")
}
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(body.reader())
}
val responseTag = "${action}Response"
val args = mutableMapOf<String, String>()
var inResponse = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
parser.next()
}
return args
}
private fun handleParserEvent(
parser: XmlPullParser,
responseTag: String,
inResponse: Boolean,
args: MutableMap<String, String>,
): Boolean = when (parser.eventType) {
XmlPullParser.START_TAG -> {
if (parser.name == responseTag) {
true
} else {
if (inResponse) {
val name = parser.name
val text = readElementContent(parser, name)
args[name] = text
}
inResponse
}
}
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
else -> inResponse
}
/**
* Read the content of the currently-started element. Try nextText() first
* (works when the content is text -- escaped XML included). If that throws
* (because the content is nested elements), manually walk to the matching
* END_TAG, accumulating text and re-serializing child elements.
*
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
* elements without escaping; this fallback recovers that path.
*/
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
return runCatching { parser.nextText() }.getOrElse {
readUntilEndTag(parser, tagName)
}
}
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
val sb = StringBuilder()
var depth = 1
var done = false
while (!done && depth > 0) {
when (parser.next()) {
XmlPullParser.START_TAG -> {
appendStartTag(sb, parser)
depth += 1
}
XmlPullParser.END_TAG -> {
depth -= 1
if (depth == 0 && parser.name == tagName) {
done = true
} else {
sb.append("</").append(parser.name).append('>')
}
}
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
XmlPullParser.END_DOCUMENT -> done = true
else -> Unit
}
}
return sb.toString()
}
private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
sb.append('<').append(parser.name)
for (i in 0 until parser.attributeCount) {
sb.append(' ')
.append(parser.getAttributeName(i))
.append("=\"")
.append(parser.getAttributeValue(i))
.append('"')
}
sb.append('>')
}
private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
private fun faultDescriptionOf(body: String): String =
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
private fun extractBetween(body: String, open: String, close: String): String? {
val start = body.indexOf(open)
if (start < 0) return null
val contentStart = start + open.length
val end = body.indexOf(close, contentStart)
return if (end < 0) null else body.substring(contentStart, end)
}
private companion object {
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
}
}
/**
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
* as a string (UPnP codes are numeric in spec but we don't constrain).
*/
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
private const val MAX_BODY_LOG_CHARS = 2048
/**
* Returns a [SoapClient] that logs the raw response body for the first
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
* callback is a no-op so there is no persistent log spam. Logged at WARN
* so release builds capture it without a separate log-level override.
*/
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
val counter = AtomicInteger(0)
return SoapClient(okHttp) { action, body ->
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
val n = counter.incrementAndGet()
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
Timber.w(
"UPnP %s response #%d (%s): %s",
label, n, action,
body.take(MAX_BODY_LOG_CHARS),
)
}
}
}
private val DIAGNOSTIC_ACTIONS = setOf(
"GetPositionInfo",
"GetTransportInfo",
"GetZoneGroupState",
)
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
internal fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
@@ -0,0 +1,126 @@
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import android.net.wifi.WifiManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.InetAddress
import java.net.MulticastSocket
import java.nio.charset.StandardCharsets
/**
* UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery
* response's LOCATION URL on the [discoveries] SharedFlow; the
* `UpnpDiscoveryController` follows up by fetching + parsing each
* device description.
*
* Passive listen is always-on once [start] is called (NOTIFY packets
* speakers send when they boot / refresh). Active discovery (an
* explicit M-SEARCH request) is triggered by [requestActiveScan] —
* called when the picker sheet opens so newly-paired devices appear
* promptly.
*
* WifiManager.MulticastLock is held while the listener is running.
* Released by [stop] / cancellation of the parent scope.
*/
class SsdpDiscovery(
private val context: Context,
) {
private val discoveriesInternal =
MutableSharedFlow<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
val discoveries: SharedFlow<String> = discoveriesInternal.asSharedFlow()
private var multicastLock: WifiManager.MulticastLock? = null
private var socket: MulticastSocket? = null
private var listenJob: Job? = null
fun start(scope: CoroutineScope) {
if (listenJob != null) return
val wifi = context.getSystemService<WifiManager>() ?: return
multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply {
setReferenceCounted(false)
acquire()
}
val sock = MulticastSocket(ANY_LOCAL_PORT).apply {
joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR))
}
socket = sock
listenJob = scope.launch(Dispatchers.IO) {
val buf = ByteArray(SOCKET_READ_BUFFER_BYTES)
val packet = DatagramPacket(buf, buf.size)
while (isActive) {
runCatching { sock.receive(packet) }.onSuccess {
val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8)
parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) }
}
}
}
}
fun stop() {
listenJob?.cancel()
listenJob = null
runCatching { socket?.close() }
socket = null
runCatching { multicastLock?.release() }
multicastLock = null
}
/**
* Send an M-SEARCH packet asking for MediaRenderer:1 devices.
* Responses arrive on the listener socket and emit via
* [discoveries] as their LOCATION URL.
*/
suspend fun requestActiveScan() = withContext(Dispatchers.IO) {
val sock = socket ?: return@withContext
val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8)
val packet = DatagramPacket(
payload,
payload.size,
InetAddress.getByName(SSDP_MULTICAST_ADDR),
SSDP_PORT,
)
runCatching { sock.send(packet) }
Unit
}
private fun buildMSearchPayload(): String = buildString {
append("M-SEARCH * HTTP/1.1\r\n")
append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n")
append("MAN: \"ssdp:discover\"\r\n")
append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n")
append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n")
append("\r\n")
}
private fun parseLocation(raw: String): String? {
raw.lineSequence().forEach { line ->
val trimmed = line.trim()
if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) {
return trimmed.substring(LOCATION_HEADER.length).trim()
}
}
return null
}
private companion object {
const val SSDP_MULTICAST_ADDR = "239.255.255.250"
const val SSDP_PORT = 1900
const val ANY_LOCAL_PORT = 0
const val SOCKET_READ_BUFFER_BYTES = 4096
const val DISCOVERY_BUFFER_CAPACITY = 32
const val MSEARCH_MX_SECONDS = 2
const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp"
const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
const val LOCATION_HEADER = "LOCATION:"
}
}
@@ -0,0 +1,261 @@
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
* device description XML, and projects discovered MediaRenderers into a
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
* system routes.
*
* Passive listen runs from process start (via `init`). Active discovery
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
* NOTIFY listener stays on for the process lifetime, so
* [downgradeDiscovery] is a no-op preserved for API parity with the
* MediaRouter-side controller.
*
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
* the same speaker replaces the prior entry rather than appending a
* duplicate row to the picker.
*/
@Singleton
class UpnpDiscoveryController @Inject constructor(
@ApplicationContext context: Context,
@ApplicationScope private val appScope: CoroutineScope,
private val okHttp: OkHttpClient,
) {
private val ssdp = SsdpDiscovery(context)
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
// this client. A dead renderer should fail fast so OutputPickerController's
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
// dispatcher stay shared with the app client.
private val controlHttp: OkHttpClient = okHttp.newBuilder()
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
init {
ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
// so the launched collector dies with the process — no explicit
// cancellation needed.
appScope.launch(Dispatchers.IO) {
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
}
}
/**
* Fire an M-SEARCH burst so newly-paired speakers appear within a
* few seconds rather than waiting for the next NOTIFY beacon
* (typical SSDP cadence: every ~30min — far too slow for a UI
* that just opened).
*/
fun upgradeDiscovery() {
appScope.launch { ssdp.requestActiveScan() }
}
/**
* No-op for the SSDP socket — passive NOTIFY listen is always on
* once [init] has run. Kept symmetric with the MediaRouter side's
* `downgradeDiscovery` so the picker controller fans out to both
* without conditional logic.
*/
fun downgradeDiscovery() {
// intentionally empty: see kdoc
}
/**
* Build an [AVTransportClient] bound to the previously-discovered
* route's control URL. Returns null when the routeId isn't in the
* current snapshot — the speaker disappeared between picker open
* and tap, or the caller passed a non-UPnP routeId. Callers handle
* the null by abandoning the selection (no crash, no fallback).
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(
loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl,
)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
refreshSonosTopology(route)
}
}
private fun upsertRoute(route: UpnpRoute) {
val current = routesInternal.value
val idx = current.indexOfFirst { it.id == route.id }
routesInternal.value = if (idx < 0) {
current + route
} else {
current.toMutableList().also { it[idx] = route }
}
}
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
Timber.w(
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
anySonos.id,
)
return
}
val groups = runCatching {
ZoneGroupTopologyClient(
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
zgtUrl,
).getZoneGroupState()
}
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
.getOrNull() ?: return
Timber.w(
"Sonos topology refreshed for %s: %d group(s)",
anySonos.id,
groups.size,
)
sonosTopologyInternal.value = groups
}
/**
* Returns the coordinator UDN's full route for the group [udn] belongs
* to, or null if topology hasn't loaded / the udn is in no group. UDN
* normalization strips the "uuid:" prefix because DeviceDescription's
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
*/
fun coordinatorRouteFor(udn: String): UpnpRoute? {
val bare = udn.bareUdn()
val coord = sonosTopologyInternal.value
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
?.coordinatorUdn ?: return null
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
}
/**
* UDNs of every NON-coordinator Sonos group member. The picker
* controller suppresses these from the visible list.
*/
fun nonCoordinatorMemberUdns(): Set<String> {
return sonosTopologyInternal.value.flatMap { g ->
g.members.map { it.udn.bareUdn() }
.filter { it != g.coordinatorUdn.bareUdn() }
}.toSet()
}
/**
* Fetch + parse the device description at [locationUrl] into a
* [UpnpRoute]. Returns null on URL parse failure, transport
* failure, empty body, or non-renderer device. Single-return form
* (chained `let`s + an early null guard) so detekt's default
* ReturnCount cap is respected.
*/
private fun fetchRoute(locationUrl: String): UpnpRoute? {
val url = locationUrl.toHttpUrlOrNull() ?: return null
return fetchBody(url)
?.let { DeviceDescription.parse(it, url) }
?.let { desc ->
UpnpRoute(
id = desc.udn,
name = displayName(desc.friendlyName, desc.manufacturer),
manufacturer = desc.manufacturer,
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
)
}
}
/**
* Clean the raw UPnP friendlyName for picker display. Sonos uses
* the format `Room - Device Type - RINCON_<UDN>`; we strip
* everything after the first " - " so the chip shows just "Living
* Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
* TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
* too. Empty / blank → "Network speaker" fallback.
*
* Device subtitle (manufacturer + model) is rendered separately
* by the picker sheet, so room-only here doesn't lose information.
*/
private fun displayName(friendlyName: String, manufacturer: String): String {
val trimmed = friendlyName.trim()
if (trimmed.isBlank()) return "Network speaker"
val byVendor = when {
manufacturer.contains("Sonos", ignoreCase = true) -> {
trimmed.substringBefore(" - ", trimmed)
}
else -> trimmed
}
val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
return withoutIpSuffix.ifBlank { "Network speaker" }
}
private fun fetchBody(url: HttpUrl): String? {
val body = runCatching {
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
it.body?.string()
}
}.getOrNull().orEmpty()
return body.ifEmpty { null }
}
private companion object {
// " (192.168.0.77)" trailing host suffix some generic UPnP
// devices append. Anchored to end-of-string so it never eats
// a legitimate parenthetical inside a name.
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
// Aggressive connect timeout for transport SOAP so a dead renderer
// trips the drop-recovery quickly. Conservative enough not to false-drop
// a slow-but-alive LAN; read timeout still inherits the shared client.
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
}
}
/**
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
*
* Three places use UDN strings with different conventions:
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
* a `_MR` suffix and the MediaServer adds `_MS`.
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
* neither the `uuid:` prefix nor any device suffix.
*
* To compare any pair of those, strip the prefix and the suffix so
* everything reduces to the underlying speaker identity.
*/
internal fun String.bareUdn(): String = removePrefix("uuid:")
.removeSuffix("_MR")
.removeSuffix("_MS")
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
* SSDP details so the picker UI consumes a narrow domain shape.
*
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
* ZoneGroupTopology service in their device description; non-Sonos devices
* leave it null. The discovery controller uses that URL to aggregate stereo
* pairs and multi-speaker groups into single picker rows.
*
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
* raw `<friendlyName>` straight from the device description — callers
* fall back to "Network speaker" upstream when it's blank; this class
* does not perform that substitution itself.
*/
data class UpnpRoute(
val id: String,
val name: String,
val manufacturer: String,
val modelName: String,
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState.
* Stereo pairs and multi-speaker groups all appear as one group with
* multiple members; one member is the coordinator we send SOAP to.
*/
data class SonosZoneGroup(
val coordinatorUdn: String,
val name: String,
val members: List<SonosZoneMember>,
)
data class SonosZoneMember(
val udn: String,
val roomName: String,
val location: String?,
val channelMapSet: String?,
)
object SonosTopology {
fun parse(xml: String): List<SonosZoneGroup> {
val effective = if (xml.contains("&lt;ZoneGroup")) {
unescapeXmlEntities(xml)
} else {
xml
}
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
}
private fun unescapeXmlEntities(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&") // must be last to avoid double-decoding
private fun parseStrict(xml: String): List<SonosZoneGroup> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader())
}
val groups = mutableListOf<SonosZoneGroup>()
var currentCoordinator: String? = null
var currentMembers: MutableList<SonosZoneMember>? = null
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.name) {
TAG_ZONE_GROUP -> {
currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR)
currentMembers = mutableListOf()
}
TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser))
}
XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) {
val members = currentMembers ?: emptyList()
val coord = currentCoordinator
if (coord != null && members.isNotEmpty()) {
val name = members.firstOrNull { it.udn == coord }?.roomName
?: members.first().roomName
groups.add(SonosZoneGroup(coord, name, members))
}
currentCoordinator = null
currentMembers = null
}
}
parser.next()
}
return groups
}
private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember(
udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(),
roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(),
location = parser.getAttributeValue(null, ATTR_LOCATION),
channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET),
)
private const val TAG_ZONE_GROUP = "ZoneGroup"
private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember"
private const val ATTR_COORDINATOR = "Coordinator"
private const val ATTR_UUID = "UUID"
private const val ATTR_ZONE_NAME = "ZoneName"
private const val ATTR_LOCATION = "Location"
private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet"
}
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import okhttp3.HttpUrl
import timber.log.Timber
/**
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
* standard UPnP service, exposed on port 1400 at
* /ZoneGroupTopology/Control. GetZoneGroupState returns the full
* network topology as one XML doc wrapped inside a SOAP arg.
*/
class ZoneGroupTopologyClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun getZoneGroupState(): List<SonosZoneGroup> {
val args = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetZoneGroupState",
args = emptyMap(),
)
val inner = args["ZoneGroupState"].orEmpty()
Timber.w(
"ZGT extracted ZoneGroupState (%d chars): %s",
inner.length,
inner.take(ZGT_LOG_TRUNCATE_CHARS),
)
return SonosTopology.parse(inner)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
const val ZGT_LOG_TRUNCATE_CHARS = 2048
}
}
@@ -59,9 +59,14 @@ fun rememberDominantColor(coverUrl: String?): Color {
val palette = withContext(Dispatchers.Default) {
Palette.from(bitmap).generate()
}
val swatch = palette.vibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
// Always use the dominant (majority-by-pixel-count) swatch.
// The cover art is the main feature of this view; a vibrant
// accent on the cover should pop against the background, not
// be matched by it. Previously we tried vibrant first, which
// picked the highest-saturation swatch even when it covered
// a tiny fraction of the cover — small bright accents made
// the gradient feel disconnected from the actual image.
val swatch = palette.dominantSwatch
if (swatch != null) {
extracted = Color(swatch.rgb)
}
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -164,6 +165,7 @@ fun MiniPlayer(
MiniRow(
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
@@ -248,15 +251,39 @@ private fun MiniRow(
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
TransportButton(
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
description = if (isPlaying) "Pause" else "Play",
MiniPlayPauseButton(
isPlaying = isPlaying,
isUpnpLoading = isUpnpLoading,
onClick = onPlayPause,
)
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
}
}
@Composable
private fun MiniPlayPauseButton(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
@Composable
private fun TransportButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -2,8 +2,13 @@
package com.fabledsword.minstrel.player.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -12,13 +17,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -31,7 +39,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.ui.unit.DpSize
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Brush
@@ -43,13 +50,26 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
@@ -65,6 +85,13 @@ import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -85,6 +112,10 @@ private const val COVER_MAX_WIDTH_DP = 320
private const val TRANSPORT_ICON_DP = 36
private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
private val SCRUB_THUMB_WIDTH_DP = 4.dp
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
// Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit;
@@ -111,27 +142,30 @@ fun NowPlayingScreen(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
}
val track = state.currentTrack
if (track == null) {
// Session torn down (queue finished + auto-stop, or user cleared
// the queue from elsewhere). Pop back to whichever shell screen
// launched NowPlaying rather than stranding the user on an
// EmptyState with no escape. A short delay swallows the
// momentary null during MediaController IPC bind on cold-mount.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
NowPlayingNullTrackGuard(navController, viewModel)
return
}
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissConnection)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent(onKeyEvent),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
@@ -148,11 +182,32 @@ fun NowPlayingScreen(
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
outputViewModel = outputViewModel,
)
}
}
}
/**
* Null-track guard extracted from [NowPlayingScreen] to keep that
* function under detekt's LongMethod ceiling. Session torn down
* (queue finished + auto-stop, or user cleared the queue from
* elsewhere). Pops back after a short grace delay so a momentary
* null during MediaController IPC bind on cold-mount doesn't flash.
*/
@Composable
private fun NowPlayingNullTrackGuard(
navController: NavHostController,
viewModel: PlayerViewModel,
) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(POP_GRACE_MS)
if (viewModel.uiState.value.currentTrack == null) {
navController.popBackStack()
}
}
}
@Composable
private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background
@@ -189,16 +244,32 @@ private fun rememberDragDismissConnection(
object : NestedScrollConnection {
private var accumulated = 0f
// One-shot latch. popBackStack is async — between the first
// dismissal and the screen actually leaving the composition,
// the user's finger is still down and more onPostScroll
// frames arrive. Without this guard the accumulator rebuilds
// and onDismiss() fires a second time, popping the screen
// BENEATH NowPlaying. If that leaves the back stack empty
// the NavHost renders nothing → black screen on resume.
private var dismissed = false
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
return if (available.y > 0f) {
// After dismissal, eat all remaining drag so the
// scrollable doesn't paint over-scroll deltas during the
// pop transition. Source guard folded into the `else if`
// chain so the function stays under detekt's ReturnCount
// ceiling of 2.
if (dismissed) return available
return if (source != NestedScrollSource.UserInput) {
Offset.Zero
} else if (available.y > 0f) {
accumulated += available.y
if (accumulated >= thresholdPx) {
accumulated = 0f
dismissed = true
onDismiss()
}
Offset(0f, available.y)
@@ -209,6 +280,7 @@ private fun rememberDragDismissConnection(
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (dismissed) return available
accumulated = 0f
return Velocity.Zero
}
@@ -235,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
)
}
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable
private fun NowPlayingBody(
inner: androidx.compose.foundation.layout.PaddingValues,
@@ -243,9 +316,47 @@ private fun NowPlayingBody(
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
NowPlayingContent(
inner = inner,
state = state,
track = track,
navController = navController,
viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel,
routes = routes,
isLiked = isLiked,
onChipTapped = outputViewModel::onChipTapped,
)
if (sheetVisible) {
OutputPickerSheet(
snapshot = routes,
permissionDenied = permissionDenied,
onRouteSelected = outputViewModel::onRouteSelected,
onDismiss = outputViewModel::onSheetDismissed,
)
}
}
@Composable
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
private fun NowPlayingContent(
inner: androidx.compose.foundation.layout.PaddingValues,
state: com.fabledsword.minstrel.player.PlayerUiState,
track: com.fabledsword.minstrel.models.TrackRef,
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
routes: RouteSnapshot,
isLiked: Boolean,
onChipTapped: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -273,27 +384,94 @@ private fun NowPlayingBody(
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat,
)
// Spec visibility rule: hide the chip when the only route is the
// built-in speaker — no picker is useful with one option. Chip
// reappears the moment a Bluetooth pair or wired plug arrives.
if (shouldShowChip(routes)) {
Spacer(Modifier.height(8.dp))
DeviceChip(
route = routes.current,
onClick = onChipTapped,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Spacer(Modifier.height(4.dp))
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
)
PlaybackControlsBlock(state = state, viewModel = viewModel)
}
}
/**
* Scrubber + transport pair, sharing the smoothed playback position.
* Extracted from [NowPlayingContent] to keep that body under detekt's
* LongMethod ceiling — the two rows belong together (the scrubber's
* smoothed position would otherwise need to be hoisted into the
* caller just to thread it into the row below).
*/
@Composable
private fun PlaybackControlsBlock(
state: com.fabledsword.minstrel.player.PlayerUiState,
viewModel: PlayerViewModel,
) {
val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs,
durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
Spacer(Modifier.height(4.dp))
TransportRow(
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext,
)
}
/**
* One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
* visibility. The launcher is remembered across recompositions; the
* LaunchedEffect fires when the sheet opens and the permission is not
* already granted. Subsequent opens are no-ops once the user has
* answered — the system remembers their choice. Returns whether the
* user explicitly denied, so the sheet can surface the rationale row.
*
* Extracted from [NowPlayingBody] to keep that body under detekt's
* LongMethod ceiling — the permission plumbing is incidental to the
* player layout.
*/
@Composable
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
val context = LocalContext.current
var permissionDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
permissionDenied = !granted
}
LaunchedEffect(sheetVisible) {
if (sheetVisible &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
return permissionDenied
}
private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
val onlyBuiltIn = snapshot.available.size == 1 &&
snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
return !onlyBuiltIn
}
@Composable
private fun BottomActionsRow(
navController: NavHostController,
@@ -458,18 +636,28 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
modifier = Modifier.fillMaxWidth(),
colors = sliderColors,
interactionSource = interactionSource,
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
// default is 20dp; halving keeps it tappable (Slider's own
// 48dp hit slop is unchanged) but lets it recede into the UI.
// Track left at default — the colour pin alone already
// matches Flutter's slate look.
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
// A small circle on a thin horizontal bar reads as visually
// off-center even when geometrically aligned — the eye
// expects the bar to bisect the thumb but a 14dp circle's
// mass extends above and below in equal amounts that the
// brain perceives as offset. A vertical pill the same width
// as the track removes the ambiguity: the bar passes through
// the pill's horizontal axis cleanly. Also matches M3's
// expressive-slider handle direction. State-layer halo is
// still dropped for visual parity with the web scrubber.
// Slider's 48dp hit slop still applies, so tapability is
// unchanged.
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = sliderColors,
thumbSize = DpSize(10.dp, 10.dp),
Box(
modifier = Modifier
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
.clip(CircleShape)
.background(accent),
)
},
// Custom 4dp rounded track — see ScrubTrack for rationale.
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -488,9 +676,37 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
}
}
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; the
* pill-thumb-on-thin-track pairing restores the "handle on a string"
* cue your eye reads as "tool, draggable." Also drops M3's stop
* indicator dot, which the web scrubber doesn't have. Extracted so
* [ScrubberRow] stays under detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(accent),
)
}
}
@Composable
private fun TransportRow(
isPlaying: Boolean,
isUpnpLoading: Boolean,
onPrev: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
@@ -509,13 +725,20 @@ private fun TransportRow(
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
if (isUpnpLoading) {
CircularProgressIndicator(
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
strokeWidth = 3.dp,
)
} else {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = actionColors.primary,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
)
}
}
IconButton(onClick = onNext) {
Icon(
@@ -527,3 +750,40 @@ private fun TransportRow(
}
}
}
/**
* Returns a key-event handler that intercepts volume-up/down when a UPnP
* route is active and routes the step through [ActiveUpnp.rendering].
* Volume is cached locally so rapid key presses don't each wait on a
* getVolume() round-trip. Returns false (not consumed) for every event
* when no UPnP route is active so the system handles volume normally.
*/
@Composable
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
val cache = remember(activeUpnp?.routeId) { VolumeCache() }
return remember(activeUpnp) {
handler@{ event: KeyEvent ->
val rc = activeUpnp?.rendering ?: return@handler false
if (event.type != KeyEventType.KeyDown) return@handler false
val delta = when (event.key) {
Key.VolumeUp -> VOLUME_KEY_STEP
Key.VolumeDown -> -VOLUME_KEY_STEP
else -> return@handler false
}
scope.launch {
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
cache.value = next
runCatching { rc.setVolume(next) }
}
true
}
}
}
private class VolumeCache(var value: Int? = null)
private const val VOLUME_KEY_STEP = 5
private const val VOLUME_MIN_PERCENT = 0
private const val VOLUME_MAX_PERCENT = 100
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
fun play() = controller.play()
fun pause() = controller.pause()
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.api.ErrorCopy
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
/**
* Fetch a playlist and hand it to the player as a shuffled queue.
*
* Behavior matches the tile play-button contract used on Home and the
* Playlists list: refreshable system playlists go through systemShuffle
* (server-side rotation-aware order; tagging with the variant advances
* rotation), everything else uses refreshDetail. System playlists are
* then client-side shuffled so the tile feels random rather than
* "start at the rotation head". User playlists keep their authored
* order.
*
* Errors and empty mixes surface through [onMessage] for the caller to
* present as a snackbar / toast / etc. Returns when the player has
* accepted the queue (or an error path bailed).
*/
suspend fun playPlaylistShuffled(
playlist: PlaylistRef,
repository: PlaylistsRepository,
player: PlayerController,
onMessage: (String) -> Unit,
) {
val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
onMessage("Mix isn't ready yet - try again in a moment")
return
}
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
// server's rotation matcher keys on the bare variant.
val source = if (playlist.refreshable) playlist.systemVariant else null
// System playlist tile play button == "pick a random song + shuffle
// the rest" UX. Server's rotation-aware order still drives rotation
// bookkeeping via `source`; the client shuffle just removes the
// deterministic "start at rotation head" feel.
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
player.setQueue(ordered, initialIndex = 0, source = source)
}
private suspend fun fetchPlaylistDetail(
playlist: PlaylistRef,
repository: PlaylistsRepository,
onMessage: (String) -> Unit,
): PlaylistDetailRef? = try {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
if (playlist.refreshable && playlist.systemVariant != null) {
repository.systemShuffle(playlist.systemVariant)
} else {
repository.refreshDetail(playlist.id)
}
}
} catch (
@Suppress("SwallowedException") _: TimeoutCancellationException,
) {
onMessage("Couldn't load playlist - check your connection")
null
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
null
}
@@ -7,6 +7,7 @@ import retrofit2.HttpException
import java.net.HttpURLConnection
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
@@ -16,7 +17,9 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
@@ -50,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
/** Owned + public; UI splits by isSystem / userId comparison. */
/**
* Owned + public; UI splits by isSystem / userId comparison. Joined with
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
* greying without each consumer re-querying the cache index.
*/
fun observeAll(): Flow<List<PlaylistRef>> =
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
combine(
playlistDao.observeAll(),
playlistDao.observeCachedCounts(),
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
/** User-owned playlists only (systemVariant IS NULL). */
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
@@ -232,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
/**
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
* from the cache-index counts. A playlist is fully cached when it has tracks
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
* of the Flow so the predicate is unit-testable without a Room database.
*/
internal fun mergePlaylistsWithCache(
rows: List<CachedPlaylistEntity>,
counts: List<PlaylistCachedCount>,
): List<PlaylistRef> {
val cachedById = counts.associate { it.playlistId to it.cachedCount }
return rows.map { row ->
val cached = cachedById[row.id] ?: 0
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
}
}
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
PlaylistRef(
id = id,
@@ -241,7 +268,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverPath ?: "",
// Server returns a relative cover path like "/api/playlists/<id>/cover";
// wrap through the placeholder host so BaseUrlInterceptor + the shared
// Coil/OkHttpClient resolve it against the live server. Without this
// wrap AsyncImage silently fails on the relative URL.
coverUrl = resolveServerUrl(coverPath) ?: "",
)
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
@@ -277,7 +308,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
isPublic = isPublic,
systemVariant = systemVariant,
trackCount = trackCount,
coverUrl = coverUrl,
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
// host for BaseUrlInterceptor to rewrite to the live server.
coverUrl = resolveServerUrl(coverUrl) ?: "",
ownerUsername = ownerUsername,
)
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -227,7 +225,16 @@ class PlaylistDetailViewModel @Inject constructor(
val refs = tracks.toPlayableTrackRefs()
if (refs.isEmpty()) return
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
// Drift #564: for refreshable system playlists, send the bare
// systemVariant string so the rotation matcher
// (systemPlaylistSources in playevents/writer.go) sees the
// play. User playlists keep the "playlist:<id>" tag for
// attribution but it doesn't trigger rotation (intentional —
// rotation only applies to system mixes).
val playlist = (internal.value as? PlaylistDetailUiState.Success)?.detail?.playlist
val source = playlist?.systemVariant?.takeIf { playlist.refreshable }
?: "playlist:$playlistId"
player.setQueue(refs, initialIndex = startIndex, source = source)
}
}
@@ -288,7 +295,10 @@ private fun PlaylistDetailContent(
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
// Crossfade keyed on `state::class` so refreshes that emit a
// fresh Success (same kind, new detail) don't re-fade the body;
// only Loading ↔ Success ↔ Error transitions animate.
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
is PlaylistDetailUiState.Loading ->
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
@@ -37,12 +45,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +65,25 @@ import javax.inject.Inject
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val poolMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
val offline: StateFlow<Boolean> = networkStatus.state
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
init {
refresh()
// Live updates: a playlist created/updated/deleted from another
@@ -69,6 +100,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() }
}
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, repository, player) {
poolMessages.trySend(it)
}
}.join()
}
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll()
.map { list ->
@@ -88,6 +128,10 @@ fun PlaylistsListScreen(
navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(),
) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -97,8 +141,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName,
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +163,9 @@ fun PlaylistsListScreen(
)
is UiState.Success -> PlaylistsGrid(
playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
)
}
}
@@ -127,7 +175,9 @@ fun PlaylistsListScreen(
@Composable
private fun PlaylistsGrid(
playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) {
val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +192,16 @@ private fun PlaylistsGrid(
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
// Greyed offline when it needs the live server or isn't fully
// cached — dimmed but still tappable into the detail.
val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
}
if (userPlaylists.isNotEmpty()) {
@@ -150,7 +209,14 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
playEnabled = playlist.trackCount > 0 && !greyed,
greyed = greyed,
)
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists.
*
* [greyed] dims the whole tile (it can't be reliably played offline) while
* keeping it tappable — the detail screen is the escape hatch for shuffling
* whatever subset is cached. Greyed always disables the play overlay too.
*/
@Composable
fun PlaylistCard(
@@ -48,11 +53,13 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true,
greyed: Boolean = false,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
modifier = modifier
.width(176.dp)
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
.clickable {
seedCache.stashPlaylist(playlist)
onClick()
@@ -63,7 +70,7 @@ fun PlaylistCard(
PlaylistCardCover(
playlist = playlist,
onPlay = onPlay,
playEnabled = playEnabled,
playEnabled = playEnabled && !greyed,
)
Spacer(Modifier.height(8.dp))
Text(
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
}
private const val PILL_BG_ALPHA = 0.85f
private const val GREYED_ALPHA = 0.45f
private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
@@ -5,10 +5,13 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
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.RequestsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -19,6 +22,10 @@ import javax.inject.Inject
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
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
@@ -30,11 +37,14 @@ class RequestsViewModel @Inject constructor(
init {
refresh()
// SSE push: reconciler completions arrive here; reload silently so the
// row updates in place rather than flashing the loading spinner.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
.collect { silentReload() }
}
viewModelScope.launch { pollWhileInFlight() }
}
fun refresh(): Job = viewModelScope.launch {
@@ -53,6 +63,43 @@ 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) {
if (hasInFlightRequest()) {
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
* disappears immediately; on server-side success the row is
@@ -71,7 +118,7 @@ class RequestsViewModel @Inject constructor(
}
viewModelScope.launch {
try {
val (_, updated) = repository.cancel(id)
val (outcome, updated) = repository.cancel(id)
if (updated != null) {
internal.update { state ->
if (state !is UiState.Success) {
@@ -87,12 +134,20 @@ class RequestsViewModel @Inject constructor(
}
}
}
// Refresh fetches the canonical list whether the cancel
// went through live (Synced) or got enqueued (Queued).
// The Queued case shows the optimistic removal until the
// replayer drains; the next list fetch surfaces the
// server's canonical view.
refresh()
// Drift #577: only refetch on the Synced outcome. The
// Queued path is offline by definition — the optimistic
// removal at the top of cancel() is correct, and a
// refresh() call here would either (a) get the
// not-yet-delivered cancelled row back from the server
// and snap it into the list (confusing), or (b) fail
// with a transport error and flip the whole screen to
// UiState.Error, making the user think the cancel
// failed. The mutation queue will replay the cancel
// when connectivity returns; the next on-screen refresh
// (pull-to-refresh, navigation back) reconciles.
if (outcome == CancelOutcome.Synced) {
refresh()
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
@@ -1,6 +1,11 @@
package com.fabledsword.minstrel.search.data
import com.fabledsword.minstrel.api.endpoints.SearchApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit
@@ -8,17 +13,35 @@ import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/**
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
* the ViewModel, not here, so the repository stays trivial.
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
* Unstable the repository hits `/api/search` and returns the server's three-facet
* paged response. When health is Offline or ServerDown it falls back to
* Room LIKE queries against `cached_*` so the user can still find
* something to play from what's already on the device; the outcome's
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
* results" hint instead of pretending the server answered.
*/
@Singleton
class SearchRepository @Inject constructor(
retrofit: Retrofit,
private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
) {
private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchResponseRef {
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
ServerHealth.Healthy, ServerHealth.Unstable ->
SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true)
}
private suspend fun remoteSearch(query: String): SearchResponseRef {
val wire = api.search(query)
return SearchResponseRef(
artists = wire.artists.items.map { it.toDomain() },
@@ -26,4 +49,21 @@ class SearchRepository @Inject constructor(
tracks = wire.tracks.items.map { it.toDomain() },
)
}
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
)
}
/**
* Wraps the search response with the signal of whether the result came
* from the server or from the local cached entities. The screen renders
* the same SearchResponseRef either way; the flag drives the offline
* banner copy.
*/
data class SearchOutcome(
val response: SearchResponseRef,
val localOnly: Boolean,
)
@@ -158,17 +158,28 @@ private fun ResultsPane(
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> {
if (state.response.isEmpty) {
CenteredHint("No matches for that query.")
} else {
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
CenteredHint(
if (state.localOnly) {
"No matches in your on-device library."
} else {
"No matches for that query."
},
)
} else {
Column(modifier = Modifier.fillMaxSize()) {
if (state.localOnly) {
OfflineResultsHint()
}
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
}
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
}
}
@Composable
private fun OfflineResultsHint() {
Text(
text = "Showing on-device matches only — the server is unreachable.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun CenteredHint(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
/** Empty query — screen shows "type to search" hint. */
data object Idle : SearchResultsState
data object Loading : SearchResultsState
data class Loaded(val response: SearchResponseRef) : SearchResultsState
data class Loaded(
val response: SearchResponseRef,
val localOnly: Boolean = false,
) : SearchResultsState
data class Error(val message: String) : SearchResultsState
}
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
private suspend fun runSearch(q: String) {
internal.update { it.copy(results = SearchResultsState.Loading) }
try {
val response = repository.search(q)
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
val outcome = repository.search(q)
internal.update {
it.copy(
results = SearchResultsState.Loaded(
response = outcome.response,
localOnly = outcome.localOnly,
),
)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
* banner clears within seconds rather than waiting for the next poll — even on
* cache-only screens whose own refresh never touches the network. Backs
* [PullToRefreshScaffold].
*/
@HiltViewModel
class PullRefreshNetworkViewModel @Inject constructor(
private val networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
fun recheck() = networkStatus.recheck()
}
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
/**
* Wraps [content] in a Material3 PullToRefreshBox so the user can
* swipe-down to trigger [onRefresh]. The wrapper manages the
* `isRefreshing` indicator while [onRefresh] is in flight.
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
* connection banner clears promptly on a deliberate user refresh.
*
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
* indicator hides exactly when the underlying coroutine completes,
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
fun PullToRefreshScaffold(
onRefresh: suspend () -> Unit,
modifier: Modifier = Modifier,
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
var isRefreshing by remember { mutableStateOf(false) }
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
scope.launch {
isRefreshing = true
try {
netVm.recheck() // deliberate pull → re-probe the server now
onRefresh()
} finally {
isRefreshing = false
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
@@ -48,6 +49,7 @@ fun ShellScaffold(
modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -61,6 +63,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
offlineWriteHintViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
// Consume the status-bar inset once here so the banner stack sits
// below the status bar (mirrors Flutter's SafeArea(bottom:false)).
// statusBarsPadding consumes the inset for descendants, so the in-
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.shared.widgets
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
/**
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
* for applying its own alpha if it should match (the row doesn't
* cascade because the trailing slot's content is the caller's, not
* ours).
*
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
* on tracks that aren't downloaded when the server is unreachable —
* fires a Toast instead of attempting playback. The text dims so the
* user can see at a glance which rows in a long list will work offline.
* The trailing slot stays interactive so the kebab / like / playlist-
* add affordances can still queue mutations for offline replay (Phase
* 5 of #618 gates those at the action level).
*/
@Composable
fun TrackRow(
@@ -45,15 +60,31 @@ fun TrackRow(
leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> Unit = {},
) {
val context = LocalContext.current
val health = LocalServerHealth.current
// Unstable is non-gating — only a real gating state dims uncached rows.
val offlineUnavailable =
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
val effectiveAlpha = if (offlineUnavailable) {
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
} else {
contentAlpha
}
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
} else {
onClick
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement,
@@ -63,7 +94,7 @@ fun TrackRow(
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha),
color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -71,7 +102,7 @@ fun TrackRow(
Text(
text = artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
* to surface the VersionTooOld banner.
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
* for both reachability and the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
@@ -1,67 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val response = runCatching { api.check() }.getOrNull() ?: return
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,8 @@
package com.fabledsword.minstrel.update.data
/**
* Result of the most recent /healthz version-compatibility check.
* `SKIPPED` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Tiny VM that exposes the [VersionCheckController]'s current
* Tiny VM that exposes the [NetworkStatusController]'s current
* [VersionResult] plus its recheck trigger for the banner button.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController,
private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result
val result: StateFlow<VersionResult> = controller.versionResult
fun recheck() = controller.recheck()
}
@@ -45,12 +45,26 @@ class AuthCookieInterceptorTest {
}
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
// AuthCookieInterceptor scopes its attach + clear behavior to
// the placeholder host so external Coil image fetches don't get
// the Minstrel session cookie attached and don't trigger a
// session-clear on 401. Tests issue requests to
// http://placeholder.invalid/... so the auth interceptor sees
// the in-scope host, and BaseUrlInterceptor (running AFTER, so
// the auth interceptor sees the original host) rewrites the URL
// before transport.
authStore.setBaseUrl(server.url("/").toString())
client =
OkHttpClient.Builder()
.addInterceptor(AuthCookieInterceptor(authStore))
.addInterceptor(BaseUrlInterceptor(authStore))
.build()
}
private fun placeholderUrl(path: String): String =
"http://${BaseUrlInterceptor.PLACEHOLDER_HOST}$path"
@AfterEach
fun teardown() {
server.shutdown()
@@ -61,7 +75,7 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=abc123")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
val recorded = server.takeRequest()
assertEquals("session=abc123", recorded.getHeader("Cookie"))
@@ -72,7 +86,7 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=expired")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
assertNull(authStore.sessionCookie.value)
}
@@ -85,7 +99,7 @@ class AuthCookieInterceptorTest {
.addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"),
)
client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/login")).build()).execute()
assertEquals("session=newvalue", authStore.sessionCookie.value)
}
@@ -95,8 +109,41 @@ class AuthCookieInterceptorTest {
authStore.setSessionCookie("session=existing")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute()
client.newCall(Request.Builder().url(placeholderUrl("/api/anything")).build()).execute()
assertEquals("session=existing", authStore.sessionCookie.value)
}
// Drift #568 regression guard: requests to external hosts must NOT
// carry the Minstrel session cookie even when the auth store has
// one. The shared OkHttpClient is also used by Coil for image
// fetches to artwork.musicbrainz.org and the like; leaking the
// session cookie to those hosts is a posture violation.
@Test
fun `does NOT attach cookie to non-placeholder host`() {
authStore.setSessionCookie("session=secret")
server.enqueue(MockResponse().setResponseCode(200))
// Request goes directly to the mock server's host:port — NOT
// through the placeholder sentinel — so the auth interceptor
// should pass it through untouched.
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
val recorded = server.takeRequest()
assertNull(recorded.getHeader("Cookie"))
}
// Drift #569 regression guard: a 401 from an external host must
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
// the user out.
@Test
fun `does NOT clear cookie on 401 from non-placeholder host`() {
authStore.setSessionCookie("session=keep")
server.enqueue(MockResponse().setResponseCode(401))
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
assertEquals("session=keep", authStore.sessionCookie.value)
}
}
@@ -0,0 +1,130 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class BaseUrlInterceptorTest {
private lateinit var server: MockWebServer
private lateinit var authStore: AuthStore
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
val dao =
mockk<AuthSessionDao> {
every { observe() } returns flowOf(null)
coEvery { get() } returns null
coEvery { upsert(any()) } returns Unit
coEvery { setSessionCookie(any()) } returns Unit
coEvery { setBaseUrl(any()) } returns Unit
}
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
}
@AfterEach
fun teardown() {
server.shutdown()
}
@Test
fun `rewrites placeholder host to live baseUrl host port and scheme`() {
// baseUrl points at the mock server; outbound request targets the
// placeholder sentinel — interceptor should rewrite to the mock.
authStore.setBaseUrl(server.url("/").toString())
server.enqueue(MockResponse().setResponseCode(200))
val client = OkHttpClient.Builder().addInterceptor(BaseUrlInterceptor(authStore)).build()
client.newCall(
Request.Builder().url("http://placeholder.invalid/api/test").build(),
).execute()
val recorded = server.takeRequest()
assertEquals("/api/test", recorded.path)
// MockWebServer's recorded Host header is the rewritten host:port
assertEquals("${server.hostName}:${server.port}", recorded.getHeader("Host"))
}
@Test
fun `passes external absolute URL through unchanged`() {
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
// coverartarchive.org) are absolute external URLs. The
// interceptor must NOT rewrite them to the Minstrel base —
// doing so produces 404s the user saw as missing Discover
// suggestion covers.
authStore.setBaseUrl(server.url("/").toString())
var seen: HttpUrl? = null
val client =
OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor(authStore))
.addInterceptor { chain ->
seen = chain.request().url
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody())
.build()
}
.build()
client.newCall(
Request.Builder()
.url("https://artwork.musicbrainz.org/release-group/abc/front-250.jpg")
.build(),
).execute()
assertEquals("artwork.musicbrainz.org", seen?.host)
assertEquals("https", seen?.scheme)
assertEquals("/release-group/abc/front-250.jpg", seen?.encodedPath)
}
@Test
fun `falls through when stored baseUrl is unparseable`() {
// Empty / malformed baseUrl can happen between cold start and
// first AuthStore load; the interceptor should leave the
// request alone rather than throw.
authStore.setBaseUrl("not a url")
var seen: HttpUrl? = null
val client =
OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor(authStore))
.addInterceptor { chain ->
seen = chain.request().url
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody())
.build()
}
.build()
client.newCall(
Request.Builder().url("http://placeholder.invalid/api/test").build(),
).execute()
assertEquals("placeholder.invalid", seen?.host)
}
}
@@ -0,0 +1,116 @@
package com.fabledsword.minstrel.connectivity
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReachabilityMachineTest {
private fun machine() = ReachabilityMachine()
@Test
fun `starts healthy`() {
val m = machine()
m.onLinkChange(up = true)
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `no link is offline regardless of probes`() {
val m = machine()
m.onLinkChange(up = false)
m.onProbeFailure(nowMs = 1)
assertEquals(ServerHealth.Offline, m.health())
}
@Test
fun `single probe failure is unstable not down`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
assertEquals(ServerHealth.Unstable, m.health())
}
@Test
fun `probe success from unstable recovers to healthy`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `op success from unstable recovers to healthy`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onSuccess() // a successful stream read / API 2xx is self-proving
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `two op failures plus a failed probe escalate immediately`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) // corroboration reached
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
assertEquals(ServerHealth.ServerDown, m.health())
}
@Test
fun `op failures with a successful probe stay healthy (track-specific)`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500)
m.onSuccess() // arbiter says server is fine
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `stale op failures do not corroborate`() {
val m = machine()
m.onLinkChange(up = true)
m.onOpFailure(nowMs = 0)
m.onOpFailure(nowMs = 1_000)
// both op failures are now older than the corroboration window:
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
}
@Test
fun `sustained failure backstop escalates after the window`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
assertEquals(ServerHealth.ServerDown, m.health())
}
@Test
fun `link restored keeps last-known down until a fresh result`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
m.onLinkChange(up = false)
assertEquals(ServerHealth.Offline, m.health())
m.onLinkChange(up = true)
// link back but no fresh probe result yet — last known reachability was down:
assertEquals(ServerHealth.ServerDown, m.health())
m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `unstable does not gate — it is not offline or serverdown`() {
val m = machine()
m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
val health = m.health()
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
assertEquals(ServerHealth.Unstable, health)
}
}
@@ -0,0 +1,59 @@
package com.fabledsword.minstrel.home.ui
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BuildPlaylistsRowTest {
private fun user(id: String, cached: Boolean) =
PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
@Test
fun `offline row leads with pools then available before greyed`() {
val owned = listOf(user("partial", cached = false), user("full", cached = true))
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
assertTrue(row[0] is PlaylistRowItem.OfflinePool)
assertTrue(row[1] is PlaylistRowItem.OfflinePool)
// Fully-cached "full" comes before the greyed "partial".
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals(listOf("full", "partial"), reals)
}
@Test
fun `offline greys a refreshable system playlist even when fully cached`() {
val forYou = PlaylistRef(
id = "fy",
userId = "u",
name = "For You",
systemVariant = "for_you",
trackCount = 1,
fullyCached = true,
)
val row = buildPlaylistsRow(
listOf(forYou, user("u1", cached = true)),
SystemPlaylistsStatus(),
offline = true,
)
// The fully-cached user playlist is available; the refreshable system
// mix needs the server, so it greys out and sorts after.
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
assertEquals(listOf("u1", "fy"), reals)
}
@Test
fun `offline row drops building-pending placeholders`() {
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
}
@Test
fun `online row has no offline pools and keeps system-slot placeholders`() {
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
}
}
@@ -0,0 +1,101 @@
package com.fabledsword.minstrel.player
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RemotePlayerStateTest {
@Test
fun `starts in idle state`() {
val state = RemotePlayerState()
assertFalse(state.isPlaying)
assertEquals(0L, state.positionMs)
assertEquals(0L, state.durationMs)
}
@Test
fun `applyPositionInfo updates position, duration, and trackNumber`() {
val state = RemotePlayerState()
state.applyPositionInfo(
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
)
assertEquals(65_000L, state.positionMs)
assertEquals(210_000L, state.durationMs)
assertEquals("x", state.currentTrackUri)
assertEquals(3, state.trackNumber)
}
@Test
fun `applyTransportPlaying flips isPlaying true`() {
val state = RemotePlayerState()
state.applyTransportPlaying()
assertTrue(state.isPlaying)
}
@Test
fun `applyTransportPaused flips isPlaying false`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
state.applyTransportPaused()
assertFalse(state.isPlaying)
}
@Test
fun `applyError resets to idle and records error`() {
val state = RemotePlayerState().apply { applyTransportPlaying() }
val ex = RuntimeException("disconnected")
state.applyError(ex)
assertFalse(state.isPlaying)
assertEquals(ex, state.lastError)
}
@Test
fun `recordPollFailure trips after threshold`() {
val state = RemotePlayerState()
// Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before
// declaring the remote dropped. Pre-threshold calls all return false.
repeat(DROP_THRESHOLD - 1) {
assertFalse(state.recordPollFailure())
}
assertTrue(state.recordPollFailure())
}
@Test
fun `recordPollSuccess clears the failure counter`() {
val state = RemotePlayerState()
repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() }
state.recordPollSuccess()
assertFalse(state.recordPollFailure())
}
@Test
fun `play intent survives a SOAP error so a failed play can resume locally`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.applyError(RuntimeException("connect timeout"))
// isPlaying is cleared by the error, but the operator's intent persists.
assertFalse(state.isPlaying)
assertTrue(state.lastPlayIntent)
}
@Test
fun `pausing clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.setPlayIntent(false)
assertFalse(state.lastPlayIntent)
}
@Test
fun `reset clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.reset()
assertFalse(state.lastPlayIntent)
}
private companion object {
const val DROP_THRESHOLD = 30
}
}

Some files were not shown because too many files have changed in this diff Show More