Compare commits

...

101 Commits

Author SHA1 Message Date
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
81 changed files with 5127 additions and 490 deletions
+21
View File
@@ -116,6 +116,27 @@ jobs:
# Wait for Postgres to accept TCP (no health-check dependency). # 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 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 # Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1: # full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB; # every integration package TRUNCATEs the one shared test DB;
+13
View File
@@ -1754,6 +1754,19 @@
<option name="screenX" value="1600" /> <option name="screenX" value="1600" />
<option name="screenY" value="2560" /> <option name="screenY" value="2560" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="35" /> <option name="api" value="35" />
<option name="brand" value="google" /> <option name="brand" value="google" />
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@@ -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')"
]
}
}
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -38,6 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
// Flipped to true when the user taps the media notification (or // Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App // any other entry point that asks for the full player). The App
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
App( App(
seedCache = seedCache, seedCache = seedCache,
cachedTrackIds = cachedTrackIds, cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(), pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false }, onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
) )
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App( private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -93,11 +99,13 @@ private fun App(
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached, LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) { ) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle() val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination val resolved = startDestination
@@ -19,7 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.VersionCheckController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -114,12 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher @Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/** /**
* Same construct-the-singleton trick — VersionCheckController's * Same construct-the-singleton trick — NetworkStatusController owns the
* init block starts a 5-min poll loop against /healthz so the * /healthz poll loop + the device-link collector + the reachability state
* shell-level VersionTooOldBanner can surface min_client_version * machine, and is the single authority on the tri-state ServerHealth
* mismatches without waiting for the next user-driven request. * 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 * Same construct-the-singleton trick — UpdateBannerController polls
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.api package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -45,9 +46,15 @@ object NetworkModule {
fun provideOkHttp( fun provideOkHttp(
baseUrl: BaseUrlInterceptor, baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor, auth: AuthCookieInterceptor,
reachability: ReachabilityReportingInterceptor,
logging: HttpLoggingInterceptor, logging: HttpLoggingInterceptor,
): OkHttpClient = ): OkHttpClient =
OkHttpClient.Builder() OkHttpClient.Builder()
// ReachabilityReportingInterceptor MUST run first: it identifies
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
// (so external artwork fetches don't read as server reachability)
// and observes the final transport outcome by wrapping the chain.
.addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor. // AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish // Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches // Minstrel-server requests from external image fetches
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
/** /**
* Response body. [url] is a fully-formed stream URL with [token] and * Response body. [url] is a fully-formed stream URL with [token] and
* [exp] already embedded as query params — callers pass it verbatim * [exp] already embedded as query params — callers pass it verbatim
* to `AVTransport.SetAVTransportURI`. * 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 @Serializable
data class StreamTokenResponse( data class StreamTokenResponse(
val token: String, val token: String,
val exp: Long, val exp: Long,
val url: String, val url: String,
val mime: String = "audio/mpeg",
val title: String = "",
) )
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id") @Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?> 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") @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -18,6 +18,14 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id") @Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?> 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") @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
@Query("SELECT * FROM cached_playlists WHERE id = :id") @Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity? suspend fun getById(id: String): CachedPlaylistEntity?
/**
* Per-playlist count of member tracks resident in the audio cache index.
* LEFT JOINs so playlists with zero cached tracks still appear
* (cachedCount = 0). Drives the offline "fully cached" greying.
*/
@Query(
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
"FROM cached_playlists p " +
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
"GROUP BY p.id",
)
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistEntity>) suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
@@ -24,6 +24,14 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity> 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") @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> 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,
)
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
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.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks * persisted in `cached_mutations.kind` so renaming a variant breaks
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, private val dao: CachedMutationDao,
private val json: Json, 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( suspend fun enqueueLikeToggle(
entityType: String, entityType: String,
entityId: String, entityId: String,
desiredState: Boolean, desiredState: Boolean,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.LIKE_TOGGLE,
kind = MutationKind.LIKE_TOGGLE, json.encodeToString(
payload = json.encodeToString( LikeTogglePayload.serializer(),
LikeTogglePayload.serializer(), LikeTogglePayload(entityType, entityId, desiredState),
LikeTogglePayload(entityType, entityId, desiredState),
),
), ),
) )
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
CachedMutationEntity( MutationKind.REQUEST_CREATE,
kind = MutationKind.REQUEST_CREATE, json.encodeToString(RequestCreatePayload.serializer(), payload),
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
) )
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_UNFLAG,
kind = MutationKind.QUARANTINE_UNFLAG, json.encodeToString(
payload = json.encodeToString( QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload.serializer(), QuarantineUnflagPayload(trackId),
QuarantineUnflagPayload(trackId),
),
), ),
) )
suspend fun enqueuePlaylistAppend( suspend fun enqueuePlaylistAppend(
playlistId: String, playlistId: String,
trackIds: List<String>, trackIds: List<String>,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.PLAYLIST_APPEND,
kind = MutationKind.PLAYLIST_APPEND, json.encodeToString(
payload = json.encodeToString( PlaylistAppendPayload.serializer(),
PlaylistAppendPayload.serializer(), PlaylistAppendPayload(playlistId, trackIds),
PlaylistAppendPayload(playlistId, trackIds),
),
), ),
) )
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
trackId: String, trackId: String,
reason: String, reason: String,
notes: String, notes: String,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_FLAG,
kind = MutationKind.QUARANTINE_FLAG, json.encodeToString(
payload = json.encodeToString( QuarantineFlagPayload.serializer(),
QuarantineFlagPayload.serializer(), QuarantineFlagPayload(trackId, reason, notes),
QuarantineFlagPayload(trackId, reason, notes), ),
), )
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
), ),
) )
@@ -136,22 +160,18 @@ class MutationQueue @Inject constructor(
), ),
) )
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CANCEL,
payload = json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
),
)
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert( suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity( CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT, kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload), payload = json.encodeToString(PlaybackErrorReportPayload.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
}
} }
/** /**
@@ -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
}
@@ -14,11 +14,23 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Single source of truth for the device's "is the internet usable * Single source of truth for "does the device have a network link at
* right now" signal — wraps [ConnectivityManager] and exposes a hot * all" — wraps [ConnectivityManager] and exposes a hot cold-startable
* cold-startable Flow that emits `false` while the active network * Flow that emits `false` only when there is no active INTERNET-capable
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier, * network (airplane mode, no carrier/Wi-Fi) and `true` once any network
* captive portal, etc.) and `true` once a usable network appears. * 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 * Used by the shell-level ConnectionErrorBanner; downstream
* repositories can also collect this to gate retry loops. * repositories can also collect this to gate retry loops.
@@ -35,36 +47,36 @@ class ConnectivityObserver @Inject constructor(
.build() .build()
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(hasUsableInternet()) trySend(hasActiveNetwork())
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(hasUsableInternet()) trySend(hasActiveNetwork())
} }
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(
network: Network, network: Network,
capabilities: NetworkCapabilities, 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( trySend(
capabilities.hasCapability( capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET, NetworkCapabilities.NET_CAPABILITY_INTERNET,
) && ),
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
),
) )
} }
} }
cm.registerNetworkCallback(request, callback) cm.registerNetworkCallback(request, callback)
// Seed the initial value so the banner doesn't flash before the // Seed the initial value so the banner doesn't flash before the
// first capability callback fires. // first capability callback fires.
trySend(hasUsableInternet()) trySend(hasActiveNetwork())
awaitClose { cm.unregisterNetworkCallback(callback) } awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged() }.distinctUntilChanged()
private fun hasUsableInternet(): Boolean { private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null && return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} }
} }
@@ -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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.ConnectivityObserver import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
private const val 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 * Exposes [NetworkStatusController]'s tri-state directly to the banner. No
* Flow into a StateFlow with the standard sharing strategy. Keeps * re-wrapping StateFlow — the controller's is already app-scoped and warm.
* the banner composable pure-presentation.
*/ */
@HiltViewModel @HiltViewModel
class ConnectivityBannerViewModel @Inject constructor( class ConnectivityBannerViewModel @Inject constructor(
observer: ConnectivityObserver, networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val online: StateFlow<Boolean> = observer.online.stateIn( val health: StateFlow<ServerHealth> = networkStatus.state
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
initialValue = true,
)
} }
/** /**
* Banner shown at the top of the shell when the device has no usable * Shell banner. Tells the user *why* they're degraded — no link vs server-down
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error * — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile * window, and a brief "Back online" confirmation when health recovers so
* data" copy. Auto-hides via slide+fade when connectivity returns. * recovery is unmistakable. Sentence case, understated voice (design system).
*/ */
@Composable @Composable
fun ConnectionErrorBanner( fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(), 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( AnimatedVisibility(
visible = !online, visible = health != ServerHealth.Healthy || showBackOnline,
enter = expandVertically() + fadeIn(), enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(), exit = shrinkVertically() + fadeOut(),
) { ) {
Row( BannerContent(health = health, backOnline = showBackOnline)
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,
)
}
} }
} }
@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 -> ""
}
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.home.data.HomeRepository import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -69,7 +70,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository 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.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -95,11 +96,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val SHARE_STOP_TIMEOUT_MS = 5_000L
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
// Recently Added is laid out in a multi-row LazyHorizontalGrid that // Recently Added is laid out in a multi-row LazyHorizontalGrid that
// scrolls as one panel (same pattern as Most Played). Two rows trades // scrolls as one panel (same pattern as Most Played). Two rows trades
@@ -136,7 +135,7 @@ class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController, private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource, private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver, networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() { ) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus()) private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -144,9 +143,13 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */ /** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow() val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
/** True when the device has no usable internet — gates the offline-pool cards. */ /**
val offline: StateFlow<Boolean> = connectivity.online * Cache-only when there's no link OR the server is unreachable. Reads the
.map { !it } * 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( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -259,45 +262,9 @@ class HomeViewModel @Inject constructor(
*/ */
suspend fun playPlaylist(playlist: PlaylistRef) { suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch { viewModelScope.launch {
val detail = try { playPlaylistShuffled(playlist, playlistsRepository, player) {
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { poolMessages.trySend(it)
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
} }
// 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
}
// Drift #564: send the BARE systemVariant string, not
// "playlist:<variant>" — the server's rotation matcher
// (internal/playevents/writer.go systemPlaylistSources)
// keys on the bare variant. Web sends the bare form too
// (web/src/lib/components/PlaylistCard.svelte:83), so this
// brings Android into alignment. Wrong prefix here meant
// system-mix plays from Android Home never advanced the
// rotation.
val source = if (playlist.refreshable) playlist.systemVariant else null
player.setQueue(tracks, initialIndex = 0, source = source)
}.join() }.join()
} }
@@ -739,18 +706,19 @@ private fun PlaylistsRow(
icon = iconForPool(item.kind), icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) }, onClick = { onPlayPool(item.kind) },
) )
is PlaylistRowItem.Real -> PlaylistCard( is PlaylistRowItem.Real -> {
playlist = item.playlist, // Greyed offline when the tile needs the live server or
onClick = { onPlaylistClick(item.playlist.id) }, // isn't fully cached — dimmed but still tappable into the
onPlay = { onPlayPlaylist(item.playlist) }, // detail to shuffle whatever subset is cached.
// Match Flutter: refreshable system playlists need val greyed = offline && item.playlist.unavailableOffline
// the live server endpoints, so disable their play PlaylistCard(
// overlay when offline (user can still tap into the playlist = item.playlist,
// detail and shuffle all from cache). User playlists onClick = { onPlaylistClick(item.playlist.id) },
// play from cache + survive offline. onPlay = { onPlayPlaylist(item.playlist) },
playEnabled = item.playlist.trackCount > 0 && playEnabled = item.playlist.trackCount > 0 && !greyed,
!(offline && item.playlist.refreshable), greyed = greyed,
) )
}
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard( is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label, label = item.label,
variant = item.variant, variant = item.variant,
@@ -766,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
} }
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */ /** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
private sealed interface PlaylistRowItem { internal sealed interface PlaylistRowItem {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
@@ -779,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
} }
/** /**
* Builds the Home Playlists row. When [offline], the two cache-backed * Builds the Home Playlists row.
* pool cards (Recently played, Liked) lead the row. Then For You + *
* Discover + 3× Songs-like fixed slots (real card when generated, * Online: For You + Discover + 3× Songs-like fixed slots (real card when
* placeholder otherwise), then the secondary system kinds (deep cuts / * generated, placeholder otherwise), then the secondary system kinds (deep cuts
* rediscover / new for you / on this day / first listens) when they * / rediscover / new for you / on this day / first listens) when they exist —
* exist — no placeholders for these since they're conditional on * no placeholders for these since they're conditional on library shape — then
* library shape, not guaranteed singletons. Finally user-owned * user-owned playlists.
* playlists. *
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
* *
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart` * Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never * `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the * surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53. * divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/ */
private fun buildPlaylistsRow( internal fun buildPlaylistsRow(
owned: List<PlaylistRef>, owned: List<PlaylistRef>,
status: SystemPlaylistsStatus, status: SystemPlaylistsStatus,
offline: Boolean, offline: Boolean,
): List<PlaylistRowItem> {
if (!offline) return buildOnlineRow(owned, status)
val out = mutableListOf<PlaylistRowItem>(
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
)
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
return out
}
/** The online layout: fixed system slots (with placeholders), secondary, user. */
private fun buildOnlineRow(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
): List<PlaylistRowItem> { ): List<PlaylistRowItem> {
val out = mutableListOf<PlaylistRowItem>() val out = mutableListOf<PlaylistRowItem>()
if (offline) {
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
}
out += owned.firstOrNull { it.systemVariant == "for_you" } out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status)) ?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
out += owned.firstOrNull { it.systemVariant == "discover" } out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status)) ?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
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) { for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i) out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) } ?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status)) ?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
} }
// Secondary system kinds in server-registry order. Only included
// when actually generated — these depend on library shape (Deep
// cuts needs deep albums, On this day needs prior history, etc.)
// so a missing one means "not enough data" rather than "still
// building".
for (variant in SECONDARY_SYSTEM_VARIANTS) { for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant } owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
?.let { out += PlaylistRowItem.Real(it) }
} }
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) } owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out return out
} }
/**
* 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 { private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building" s.inFlight -> "building"
s.lastError != null -> "failed" s.lastError != null -> "failed"
@@ -20,6 +20,8 @@ data class PlaylistRef(
val trackCount: Int = 0, val trackCount: Int = 0,
val coverUrl: String = "", val coverUrl: String = "",
val ownerUsername: String = "", val ownerUsername: String = "",
/** All member tracks resident in the audio cache — playable fully offline. */
val fullyCached: Boolean = false,
) { ) {
val isSystem: Boolean get() = systemVariant != null val isSystem: Boolean get() = systemVariant != null
@@ -30,6 +32,13 @@ data class PlaylistRef(
* expose a refresh trigger; rebuilds are scheduler-driven). * expose a refresh trigger; rebuilds are scheduler-driven).
*/ */
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist" val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
/**
* Can't be relied on while offline — either it needs the live server to
* (re)generate (a refreshable system mix) or not all its tracks are cached.
* Callers combine this with the current offline state to grey the tile.
*/
val unavailableOffline: Boolean get() = refreshable || !fullyCached
} }
/** /**
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
private val activeJobs = mutableMapOf<String, Job>() private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex() private val mutex = Mutex()
private data class ReconcileInput(
val queue: List<Pair<String, String>>,
val index: Int,
val window: Int,
val isPlaying: Boolean,
)
init { init {
scope.launch { scope.launch {
combine( combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } }, playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex }, playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow }, 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() .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( private suspend fun reconcile(
queue: List<Pair<String, String>>, queue: List<Pair<String, String>>,
index: Int, index: Int,
window: Int, window: Int,
isPlaying: Boolean,
) { ) {
mutex.withLock { mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) { val targets = computeTargets(queue, index, window)
if (targets.isEmpty()) {
cancelAllLocked() cancelAllLocked()
return 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 } 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. private fun computeTargets(
activeJobs.entries queue: List<Pair<String, String>>,
.filter { it.key !in targetIds } index: Int,
.toList() window: Int,
.forEach { (id, job) -> ): List<Pair<String, String>> {
job.cancel() // Exclude the currently-playing track (it's loaded by the player
activeJobs.remove(id) // 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 private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
// come from minimal TrackRefs synthesized from playlist rows activeJobs.entries
// when the upstream track was removed from the library). .filter { it.key !in targetIds }
for ((trackId, streamUrl) in targets) { .toList()
if (trackId in activeJobs || streamUrl.isBlank()) continue .forEach { (id, job) ->
val job = scope.launch(Dispatchers.IO) { job.cancel()
runCatching { prefetchOne(trackId, streamUrl) } activeJobs.remove(id)
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
} }
}
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,517 @@
@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.TransportState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
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: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that 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 {
scope.launch {
runCatching { active.avTransport.play() }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(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 {
scope.launch {
runCatching { active.avTransport.pause() }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(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 { active.avTransport.seek(positionMs) }
.onFailure { handleSoapFailure(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 {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}.onFailure { handleSoapFailure(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 { active.avTransport.next() }
.onFailure { handleSoapFailure(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 { active.avTransport.previous() }
.onFailure { handleSoapFailure(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()
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t)
handler.post { onDrop(active.routeName) }
}
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
}
}
@@ -72,11 +72,12 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val player = playerFactory.build() val player: Player = playerFactory.build()
val callback = LikeMediaCallback(likesRepository, serviceScope) val callback = LikeMediaCallback(likesRepository, serviceScope)
val session = MediaSession.Builder(this, player) val session = MediaSession.Builder(this, player)
.setSessionActivity(buildNowPlayingPendingIntent()) .setSessionActivity(buildNowPlayingPendingIntent())
.setCallback(callback) .setCallback(callback)
.setBitmapLoader(playerFactory.buildBitmapLoader())
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false))) .setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
.build() .build()
mediaSession = session mediaSession = session
@@ -176,7 +177,11 @@ class MinstrelPlayerService : MediaSessionService() {
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent) 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) { if (!activelyPlaying) {
stopSelf() stopSelf()
} }
@@ -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)
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
class PlaybackErrorReporter @Inject constructor( class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController, private val playerController: PlayerController,
private val repository: PlaybackErrorRepository, private val repository: PlaybackErrorRepository,
private val networkStatus: NetworkStatusController,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val outChannel = Channel<String>(Channel.BUFFERED) private val outChannel = Channel<String>(Channel.BUFFERED)
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
val buffer = mutableListOf<String>() val buffer = mutableListOf<String>()
var debounceJob: kotlinx.coroutines.Job? = null var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { event -> playerController.playbackErrorEvents.collect { event ->
// A track failing to play is ambiguous (dead server vs. one bad
// file) — let the controller arbitrate via /healthz. No-op when
// already Offline; cheap otherwise.
networkStatus.reportFailure()
// Fire-and-forget the server report — repository handles // Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws. // success/queue branching so callers don't see throws.
scope.launch { repository.report(event) } scope.launch { repository.report(event) }
@@ -5,6 +5,8 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
@@ -20,8 +22,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -59,7 +63,17 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController, private val radio: RadioController,
private val 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 = private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -130,11 +144,24 @@ class PlayerController @Inject constructor(
// ── Transport (no-op until the controller is connected) ────────────── // ── Transport (no-op until the controller is connected) ──────────────
fun play() { mediaController?.play() } // Each transport call must run on the MediaController's
fun pause() { mediaController?.pause() } // applicationLooper; calling from a background coroutine throws
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) } // IllegalStateException (see PlayerController.setQueue's note).
fun skipToNext() { mediaController?.seekToNextMediaItem() } // UI tap handlers are already on Main so the in-place branch hits;
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } // 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. */ /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
fun toggleShuffle() { fun toggleShuffle() {
@@ -299,7 +326,15 @@ class PlayerController @Inject constructor(
* and advance past the dead track. Otherwise no-op. * and advance past the dead track. Otherwise no-op.
*/ */
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) { private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return // 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 duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return if (!isZeroDuration) return
@@ -341,6 +376,19 @@ class PlayerController @Inject constructor(
// awaitReady, the Player.Listener is wired too. // awaitReady, the Player.Listener is wired too.
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit) if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
startPositionPolling(controller) 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( controller.addListener(
object : Player.Listener { object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
@@ -364,6 +412,11 @@ class PlayerController @Inject constructor(
// Reset the per-item evaluation guard so the new // Reset the per-item evaluation guard so the new
// item's STATE_READY transition gets a fresh check. // item's STATE_READY transition gets a fresh check.
lastEvaluatedItemIndex = -1 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) { override fun onPlaybackStateChanged(playbackState: Int) {
@@ -381,15 +434,38 @@ class PlayerController @Inject constructor(
?.mediaMetadata ?.mediaMetadata
?.extras ?.extras
?.getString(MINSTREL_SOURCE_KEY) ?.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 = uiStateInternal.value =
PlayerUiState( PlayerUiState(
currentTrack = current, currentTrack = current,
queue = queueRefs, queue = queueRefs,
queueIndex = idx, queueIndex = idx,
isPlaying = player.isPlaying, isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING, isBuffering = !upnpActive &&
positionMs = player.currentPosition.coerceAtLeast(0), player.playbackState == Player.STATE_BUFFERING,
durationMs = player.duration.coerceAtLeast(0), 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), bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message, playbackError = player.playerError?.message,
currentSource = source, currentSource = source,
@@ -399,6 +475,7 @@ class PlayerController @Inject constructor(
Player.REPEAT_MODE_ONE -> RepeatMode.ONE Player.REPEAT_MODE_ONE -> RepeatMode.ONE
else -> RepeatMode.OFF else -> RepeatMode.OFF
}, },
isUpnpLoading = isUpnpLoading,
) )
} }
}, },
@@ -423,15 +500,138 @@ class PlayerController @Inject constructor(
scope.launch(Dispatchers.Main.immediate) { scope.launch(Dispatchers.Main.immediate) {
while (isActive) { while (isActive) {
delay(POSITION_POLL_INTERVAL_MS) delay(POSITION_POLL_INTERVAL_MS)
if (!controller.isPlaying) continue tickPositionPoll(controller)
uiStateInternal.value = uiStateInternal.value.copy(
positionMs = controller.currentPosition.coerceAtLeast(0),
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
)
} }
} }
} }
/**
* 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()` * Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava` * to a suspend function without pulling in `kotlinx-coroutines-guava`
@@ -467,7 +667,23 @@ class PlayerController @Inject constructor(
.setArtist(artistName) .setArtist(artistName)
.setAlbumTitle(albumTitle) .setAlbumTitle(albumTitle)
.apply { .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)) 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() .build()
// Server's stream_url is a relative path (/api/tracks/{id}/stream); // Server's stream_url is a relative path (/api/tracks/{id}/stream);
@@ -489,8 +705,40 @@ class PlayerController @Inject constructor(
private fun sourceExtras(source: String): Bundle = private fun sourceExtras(source: String): Bundle =
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) } 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 { companion object {
const val MINSTREL_SOURCE_KEY: String = "minstrel_source" 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
} }
} }
@@ -3,7 +3,10 @@ package com.fabledsword.minstrel.player
import android.content.Context import android.content.Context
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor 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.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import dagger.hilt.android.qualifiers.ApplicationContext 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 okhttp3.OkHttpClient
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
* `build()` once during onCreate. * `build()` once during onCreate.
* *
@@ -31,12 +40,22 @@ import javax.inject.Singleton
* (sizeBytes cap = rollingCap); our policy layer in the worker layers * (sizeBytes cap = rollingCap); our policy layer in the worker layers
* the 2-bucket protection on top by feeding `removeSpan` only for * the 2-bucket protection on top by feeding `removeSpan` only for
* unprotected tracks. * 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 @Singleton
class PlayerFactory @Inject constructor( class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig, 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() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -46,11 +65,36 @@ class PlayerFactory @Inject constructor(
StandaloneDatabaseProvider(context), 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) 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() val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache) .setCache(simpleCache)
.setUpstreamDataSourceFactory(httpDataSource) .setUpstreamDataSourceFactory(gatedUpstream)
.setCacheWriteDataSinkFactory( .setCacheWriteDataSinkFactory(
CacheDataSink.Factory() CacheDataSink.Factory()
.setCache(simpleCache) .setCache(simpleCache)
@@ -71,4 +115,26 @@ class PlayerFactory @Inject constructor(
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .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 currentSource: String? = null,
val shuffleEnabled: Boolean = false, val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.OFF, 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,98 @@
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}()
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
*
* The poll-failure counter implements the rolling-3 drop heuristic: 3
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Success resets it.
*/
@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
// 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 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
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
}
}
@@ -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 }
}
@@ -1,24 +1,34 @@
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
package com.fabledsword.minstrel.player.output package com.fabledsword.minstrel.player.output
import android.content.Context import android.content.Context
import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController 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.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.Retrofit import kotlinx.coroutines.sync.Mutex
import retrofit2.create import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,9 +36,9 @@ import javax.inject.Singleton
/** /**
* Snapshot of the audio output route state. [current] is the live * Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route the * route audio is being delivered to. [available] is every route the
* picker knows about MediaRouter system routes merged with * picker knows about -- MediaRouter system routes merged with
* UPnP/DLNA renderers discovered on the LAN — sorted current-first * UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). * "Phone speaker" pinned first, everything else alphabetical.
*/ */
data class RouteSnapshot( data class RouteSnapshot(
val current: OutputRoute, val current: OutputRoute,
@@ -48,7 +58,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth) * wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via * - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [CastApi.streamToken], drive the discovered renderer with * [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so * AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker * audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -65,10 +75,12 @@ class OutputPickerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController, private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController, private val playerController: PlayerController,
retrofit: Retrofit, private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
) { ) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context) private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder() private val selector = MediaRouteSelector.Builder()
@@ -82,12 +94,26 @@ class OutputPickerController @Inject constructor(
*/ */
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter()) private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
private val selectUpnpMutex = Mutex()
val routesState: StateFlow<RouteSnapshot> = combine( val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal, systemRoutesInternal,
upnpDiscovery.routes, upnpDiscovery.routes,
) { sys, upnp -> upnpDiscovery.sonosTopology,
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) } selectedUpnpRouteIdInternal,
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged)) ) { 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) }.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() { private val callback = object : MediaRouter.Callback() {
@@ -113,6 +139,14 @@ class OutputPickerController @Inject constructor(
) = refresh() ) = 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
init { init {
// Two-arg addCallback registers with no discovery flag — // Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior: // androidx.mediarouter 1.7.0's default passive behavior:
@@ -120,6 +154,232 @@ class OutputPickerController @Inject constructor(
// without forcing Bluetooth scans. (There is no // without forcing Bluetooth scans. (There is no
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.) // CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback) mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (3+ consecutive poll failures or a transport SOAP exception),
// 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() }
}
/**
* 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 (poll-failure
* threshold or SOAP exception). Captures the last remote position +
* play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
playerController.seekTo(capturedPositionMs)
if (wasPlayingRemote) playerController.play()
} }
/** /**
@@ -163,48 +423,198 @@ class OutputPickerController @Inject constructor(
} }
private fun selectSystem(route: OutputRoute) { 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 val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target) mediaRouter.selectRoute(target)
} }
/** /**
* Drive the UPnP renderer: mint a token for the currently playing * Drive the UPnP renderer using Sonos native queue mode:
* track, set the renderer's URI, play, then pause local playback so * clear the device's queue, load every track from our local queue
* audio yields to the speaker. Wrapped in `runCatching` at each * via AddURIToQueue, point the transport at the queue URI, seek to
* step — token failure, transport-lookup failure, and SOAP failure * the current index, and play. Wrapped in `runCatching` — SOAP
* each abandon the selection cleanly rather than crashing. Failures * failure abandons the selection cleanly.
* log at warn level via Timber so on-device verification can find *
* the cause in logcat (OkHttp's logger doesn't cover our own * Order of operations is deliberate:
* deserialize / SOAP-parse code paths). * 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) { private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
val trackId = playerController.uiState.value.currentTrack?.id val uiState = playerController.uiState.value
if (trackId == null) { val currentTrack = uiState.currentTrack
if (currentTrack == null) {
Timber.w("UPnP select skipped: no currentTrack (start playback first)") Timber.w("UPnP select skipped: no currentTrack (start playback first)")
return return@withLock
} }
val transport = upnpDiscovery.transportFor(route.id) // 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) { if (transport == null) {
Timber.w( Timber.w(
"UPnP select skipped: no transport for route id=${route.id} " + "UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
"(route disappeared or id mismatch with discovery list)", "(route disappeared or id mismatch with discovery list)",
) )
return 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 { runCatching {
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}") loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId)) // Wire active LAST -- SOAP path is now safe to use.
Timber.i("UPnP select: SetAVTransportURI to ${token.url}") activeUpnpHolder.set(
transport.setAVTransportURI(token.url) ActiveUpnp(
Timber.i("UPnP select: Play") routeId = effectiveRoute.id,
transport.play() routeName = effectiveRoute.name,
playerController.pause() avTransport = transport,
Timber.i("UPnP select: done") rendering = rendering,
),
)
}.onFailure { e -> }.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}") 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() { private fun refresh() {
systemRoutesInternal.value = snapshotFromRouter() systemRoutesInternal.value = snapshotFromRouter()
} }
@@ -214,24 +624,21 @@ class OutputPickerController @Inject constructor(
.filter { it.matchesSelector(selector) } .filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) } .map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute) val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(current, all)) return RouteSnapshot(current = current, available = sortRoutes(all))
} }
/** /**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then * BuiltIn "Phone speaker" pinned first; everything else
* Other (UPnP renderers fall in Other). Keeps the active output at * alphabetical. Selection state is conveyed by the radio button
* the top + likely-wanted alternatives next + fallback last. * indicator in the picker row, not by sort order.
*/ */
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> { private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route -> val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
when { return builtIn + rest.sortedBy { it.name.lowercase() }
route.id == current.id -> 0 }
route.kind == OutputRoute.Kind.Bluetooth -> 1
route.kind == OutputRoute.Kind.Wired -> 2 private companion object {
route.kind == OutputRoute.Kind.BuiltIn -> 3 const val EXTEND_ABORT_AFTER_FAILURES = 3
else -> 4 const val EXTEND_THROTTLE_MS = 50L
}
}
return all.sortedBy(rank)
} }
} }
@@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -69,12 +72,19 @@ fun OutputPickerSheet(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
) )
snapshot.available.forEach { route -> LazyColumn(
RouteRow( modifier = Modifier
route = route, .fillMaxWidth()
isSelected = route.id == snapshot.current.id, .heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
onClick = { onRouteSelected(route) }, 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) { if (permissionDenied) {
PermissionHintRow() PermissionHintRow()
@@ -198,3 +208,4 @@ private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
private const val ROW_ICON_DP = 24 private const val ROW_ICON_DP = 24
private const val HINT_ICON_DP = 20 private const val HINT_ICON_DP = 20
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
@@ -24,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OutputPickerViewModel @Inject constructor( class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController, private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() { ) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState val routes: StateFlow<RouteSnapshot> = controller.routesState
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
initialValue = controller.routesState.value, initialValue = controller.routesState.value,
) )
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private val sheetVisibleInternal = MutableStateFlow(false) private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow() val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
@@ -3,12 +3,13 @@ package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl import okhttp3.HttpUrl
/** /**
* High-level wrapper for the UPnP AVTransport service. Three calls * High-level wrapper for the UPnP AVTransport service.
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred * Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* until we have hardware in the loop to verify each device's quirks * GetPositionInfo / GetTransportInfo / queue management
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs * (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
* reject Pause without DIDL).
*/ */
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient( class AVTransportClient(
private val soap: SoapClient, private val soap: SoapClient,
private val controlUrl: HttpUrl, private val controlUrl: HttpUrl,
@@ -26,6 +27,135 @@ class AVTransportClient(
) )
} }
/**
* 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() { suspend fun play() {
soap.call( soap.call(
controlUrl = controlUrl, controlUrl = controlUrl,
@@ -35,6 +165,15 @@ class AVTransportClient(
) )
} }
suspend fun pause() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Pause",
args = mapOf("InstanceID" to "0"),
)
}
suspend fun stop() { suspend fun stop() {
soap.call( soap.call(
controlUrl = controlUrl, controlUrl = controlUrl,
@@ -44,7 +183,114 @@ class AVTransportClient(
) )
} }
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 { private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" 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)
@@ -22,10 +22,12 @@ data class DeviceDescription(
val modelName: String, val modelName: String,
val avTransportControlUrl: HttpUrl, val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?, val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
) { ) {
companion object { companion object {
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" 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 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_SERVICE = "service"
private const val TAG_UDN = "UDN" private const val TAG_UDN = "UDN"
@@ -43,6 +45,7 @@ data class DeviceDescription(
*/ */
fun parse(xml: String, base: HttpUrl): DeviceDescription? { fun parse(xml: String, base: HttpUrl): DeviceDescription? {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply { val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(xml.reader()) setInput(xml.reader())
} }
val acc = ParseState() val acc = ParseState()
@@ -58,6 +61,7 @@ data class DeviceDescription(
modelName = acc.modelName, modelName = acc.modelName,
avTransportControlUrl = avt, avTransportControlUrl = avt,
renderingControlUrl = acc.rcControlUrl, renderingControlUrl = acc.rcControlUrl,
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
) )
} }
@@ -91,6 +95,7 @@ data class DeviceDescription(
when (acc.serviceType) { when (acc.serviceType) {
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
} }
acc.inService = false acc.inService = false
} }
@@ -115,6 +120,7 @@ data class DeviceDescription(
var modelName: String = "" var modelName: String = ""
var avtControlUrl: HttpUrl? = null var avtControlUrl: HttpUrl? = null
var rcControlUrl: HttpUrl? = null var rcControlUrl: HttpUrl? = null
var zgtControlUrl: HttpUrl? = null
var inService: Boolean = false var inService: Boolean = false
var serviceType: String = "" var serviceType: String = ""
var serviceControlUrl: 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
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.output.upnp package com.fabledsword.minstrel.player.output.upnp
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl import okhttp3.HttpUrl
@@ -8,6 +9,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
/** /**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than * Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
@@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory
*/ */
class SoapClient( class SoapClient(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) { ) {
suspend fun call( suspend fun call(
controlUrl: HttpUrl, controlUrl: HttpUrl,
@@ -41,6 +44,7 @@ class SoapClient(
.build() .build()
okHttp.newCall(request).execute().use { response -> okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty() val body = response.body?.string().orEmpty()
onRawResponse?.invoke(action, body)
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body)) throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
} }
@@ -69,15 +73,9 @@ class SoapClient(
append("</s:Envelope>") append("</s:Envelope>")
} }
private fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun parseResponseArgs(body: String, action: String): Map<String, String> { private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply { val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
setInput(body.reader()) setInput(body.reader())
} }
val responseTag = "${action}Response" val responseTag = "${action}Response"
@@ -102,7 +100,7 @@ class SoapClient(
} else { } else {
if (inResponse) { if (inResponse) {
val name = parser.name val name = parser.name
val text = runCatching { parser.nextText() }.getOrDefault("") val text = readElementContent(parser, name)
args[name] = text args[name] = text
} }
inResponse inResponse
@@ -112,6 +110,59 @@ class SoapClient(
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 = private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown" extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
@@ -138,3 +189,42 @@ class SoapClient(
*/ */
class SoapFaultException(val code: String, val description: String) : class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description") 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;")
@@ -1,7 +1,10 @@
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
package com.fabledsword.minstrel.player.output.upnp package com.fabledsword.minstrel.player.output.upnp
import android.content.Context import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope 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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -13,6 +16,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -44,6 +48,9 @@ class UpnpDiscoveryController @Inject constructor(
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList()) private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow() val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
init { init {
ssdp.start(appScope) ssdp.start(appScope)
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default), // appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
@@ -83,13 +90,77 @@ class UpnpDiscoveryController @Inject constructor(
*/ */
fun transportFor(routeId: String): AVTransportClient? { fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl) return AVTransportClient(
loggingSoapClient(okHttp, route.name),
route.avTransportControlUrl,
)
} }
private suspend fun handleDiscovery(locationUrl: String) { private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return val route = fetchRoute(locationUrl) ?: return
routesInternal.value = upsertRoute(route)
routesInternal.value.filterNot { it.id == route.id } + 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()
} }
/** /**
@@ -111,6 +182,7 @@ class UpnpDiscoveryController @Inject constructor(
modelName = desc.modelName, modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl, avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl, renderingControlUrl = desc.renderingControlUrl,
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
) )
} }
} }
@@ -155,3 +227,20 @@ class UpnpDiscoveryController @Inject constructor(
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""") val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
} }
} }
/**
* 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")
@@ -7,10 +7,10 @@ import okhttp3.HttpUrl
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP / * Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
* SSDP details so the picker UI consumes a narrow domain shape. * SSDP details so the picker UI consumes a narrow domain shape.
* *
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds * Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
* (group join/leave, zone topology) live in a separate Sonos extension * ZoneGroupTopology service in their device description; non-Sonos devices
* scoped in * leave it null. The discovery controller uses that URL to aggregate stereo
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md. * pairs and multi-speaker groups into single picker rows.
* *
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the * [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
* raw `<friendlyName>` straight from the device description — callers * raw `<friendlyName>` straight from the device description — callers
@@ -24,4 +24,5 @@ data class UpnpRoute(
val modelName: String, val modelName: String,
val avTransportControlUrl: HttpUrl, val avTransportControlUrl: HttpUrl,
val renderingControlUrl: 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
}
}
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -164,6 +165,7 @@ fun MiniPlayer(
MiniRow( MiniRow(
track = track, track = track,
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
isLiked = isLiked, isLiked = isLiked,
onExpandClick = onExpandClick, onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() }, onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
private fun MiniRow( private fun MiniRow(
track: TrackRef, track: TrackRef,
isPlaying: Boolean, isPlaying: Boolean,
isUpnpLoading: Boolean,
isLiked: Boolean, isLiked: Boolean,
onExpandClick: () -> Unit, onExpandClick: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
@@ -248,15 +251,39 @@ private fun MiniRow(
} }
LikeButton(liked = isLiked, onToggle = onToggleLike) LikeButton(liked = isLiked, onToggle = onToggleLike)
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev) TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
TransportButton( MiniPlayPauseButton(
icon = if (isPlaying) Lucide.Pause else Lucide.Play, isPlaying = isPlaying,
description = if (isPlaying) "Pause" else "Play", isUpnpLoading = isUpnpLoading,
onClick = onPlayPause, onClick = onPlayPause,
) )
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext) 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 @Composable
private fun TransportButton( private fun TransportButton(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -50,10 +52,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.draw.clip 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.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -74,11 +85,13 @@ import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode 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.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -129,27 +142,30 @@ fun NowPlayingScreen(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
}
val track = state.currentTrack val track = state.currentTrack
if (track == null) { if (track == null) {
// Session torn down (queue finished + auto-stop, or user cleared NowPlayingNullTrackGuard(navController, viewModel)
// 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()
}
}
return 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 dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection( val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() }, onDismiss = { navController.popBackStack() },
) )
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection), modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissConnection)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent(onKeyEvent),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -166,11 +182,32 @@ fun NowPlayingScreen(
navController = navController, navController = navController,
viewModel = viewModel, viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel, 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 @Composable
private fun dominantGradient(top: Color): Brush { private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background val base = MaterialTheme.colorScheme.background
@@ -270,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
) )
} }
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable @Composable
private fun NowPlayingBody( private fun NowPlayingBody(
inner: androidx.compose.foundation.layout.PaddingValues, inner: androidx.compose.foundation.layout.PaddingValues,
@@ -278,10 +316,10 @@ private fun NowPlayingBody(
navController: NavHostController, navController: NavHostController,
viewModel: PlayerViewModel, viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel, trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) { ) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id) val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle() val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle() val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible) val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
@@ -387,6 +425,7 @@ private fun PlaybackControlsBlock(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
TransportRow( TransportRow(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
onPrev = viewModel::skipToPrevious, onPrev = viewModel::skipToPrevious,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() }, onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onNext = viewModel::skipToNext, onNext = viewModel::skipToNext,
@@ -667,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
@Composable @Composable
private fun TransportRow( private fun TransportRow(
isPlaying: Boolean, isPlaying: Boolean,
isUpnpLoading: Boolean,
onPrev: () -> Unit, onPrev: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
@@ -685,13 +725,20 @@ private fun TransportRow(
modifier = Modifier.size(TRANSPORT_ICON_DP.dp), modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
) )
} }
IconButton(onClick = onPlayPause) { IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
Icon( if (isUpnpLoading) {
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play, CircularProgressIndicator(
contentDescription = if (isPlaying) "Pause" else "Play", modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
tint = actionColors.primary, strokeWidth = 3.dp,
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.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) { IconButton(onClick = onNext) {
Icon( Icon(
@@ -703,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.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
fun play() = controller.play() fun play() = controller.play()
fun pause() = controller.pause() 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 java.net.HttpURLConnection
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue import com.fabledsword.minstrel.cache.mutations.MutationQueue
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
// ── Reads (Flow, cache-first; the cache is the source of truth) ── // ── Reads (Flow, cache-first; the cache is the source of truth) ──
/** Owned + public; UI splits by isSystem / userId comparison. */ /**
* Owned + public; UI splits by isSystem / userId comparison. Joined with
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
* greying without each consumer re-querying the cache index.
*/
fun observeAll(): Flow<List<PlaylistRef>> = fun observeAll(): Flow<List<PlaylistRef>> =
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). */ /** User-owned playlists only (systemVariant IS NULL). */
fun observeUserPlaylists(): Flow<List<PlaylistRef>> = fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
// ── Mappers (internal — keep Room + wire types out of the UI layer) ── // ── Mappers (internal — keep Room + wire types out of the UI layer) ──
/**
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
* from the cache-index counts. A playlist is fully cached when it has tracks
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
* of the Flow so the predicate is unit-testable without a Room database.
*/
internal fun mergePlaylistsWithCache(
rows: List<CachedPlaylistEntity>,
counts: List<PlaylistCachedCount>,
): List<PlaylistRef> {
val cachedById = counts.associate { it.playlistId to it.cachedCount }
return rows.map { row ->
val cached = cachedById[row.id] ?: 0
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
}
}
private fun CachedPlaylistEntity.toDomain(): PlaylistRef = private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
PlaylistRef( PlaylistRef(
id = id, id = id,
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -23,11 +27,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists 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.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
import com.fabledsword.minstrel.shared.UiState import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard 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 com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ─────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────
// ─── ViewModel ─────────────────────────────────────────────────────── // ─── ViewModel ───────────────────────────────────────────────────────
@@ -50,9 +65,25 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PlaylistsListViewModel @Inject constructor( class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
networkStatus: NetworkStatusController,
) : ViewModel() { ) : 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 { init {
refresh() refresh()
// Live updates: a playlist created/updated/deleted from another // Live updates: a playlist created/updated/deleted from another
@@ -69,6 +100,15 @@ class PlaylistsListViewModel @Inject constructor(
runCatching { repository.refreshList() } 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>>> = val uiState: StateFlow<UiState<List<PlaylistRef>>> =
repository.observeAll() repository.observeAll()
.map { list -> .map { list ->
@@ -88,6 +128,10 @@ fun PlaylistsListScreen(
navController: NavHostController, navController: NavHostController,
viewModel: PlaylistsListViewModel = hiltViewModel(), viewModel: PlaylistsListViewModel = hiltViewModel(),
) { ) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -97,8 +141,10 @@ fun PlaylistsListScreen(
currentRouteName = Playlists::class.qualifiedName, currentRouteName = Playlists::class.qualifiedName,
) )
}, },
snackbarHost = { SnackbarHost(snackbar) },
) { inner -> ) { inner ->
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
@@ -117,7 +163,9 @@ fun PlaylistsListScreen(
) )
is UiState.Success -> PlaylistsGrid( is UiState.Success -> PlaylistsGrid(
playlists = s.data, playlists = s.data,
offline = offline,
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
onPlay = viewModel::playPlaylist,
) )
} }
} }
@@ -127,7 +175,9 @@ fun PlaylistsListScreen(
@Composable @Composable
private fun PlaylistsGrid( private fun PlaylistsGrid(
playlists: List<PlaylistRef>, playlists: List<PlaylistRef>,
offline: Boolean,
onPlaylistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit,
onPlay: suspend (PlaylistRef) -> Unit,
) { ) {
val systemPlaylists = playlists.filter { it.isSystem } val systemPlaylists = playlists.filter { it.isSystem }
val userPlaylists = playlists.filter { !it.isSystem } val userPlaylists = playlists.filter { !it.isSystem }
@@ -142,7 +192,16 @@ private fun PlaylistsGrid(
SectionHeader("System playlists") SectionHeader("System playlists")
} }
items(items = systemPlaylists, key = { it.id }) { playlist -> 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()) { if (userPlaylists.isNotEmpty()) {
@@ -150,7 +209,14 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists") SectionHeader("Your playlists")
} }
items(items = userPlaylists, key = { it.id }) { playlist -> 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home * [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side * uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists. * shuffle endpoint is unreachable) and for empty playlists.
*
* [greyed] dims the whole tile (it can't be reliably played offline) while
* keeping it tappable — the detail screen is the escape hatch for shuffling
* whatever subset is cached. Greyed always disables the play overlay too.
*/ */
@Composable @Composable
fun PlaylistCard( fun PlaylistCard(
@@ -48,11 +53,13 @@ fun PlaylistCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null, onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true, playEnabled: Boolean = true,
greyed: Boolean = false,
) { ) {
val seedCache = LocalDetailSeedCache.current val seedCache = LocalDetailSeedCache.current
Surface( Surface(
modifier = modifier modifier = modifier
.width(176.dp) .width(176.dp)
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
.clickable { .clickable {
seedCache.stashPlaylist(playlist) seedCache.stashPlaylist(playlist)
onClick() onClick()
@@ -63,7 +70,7 @@ fun PlaylistCard(
PlaylistCardCover( PlaylistCardCover(
playlist = playlist, playlist = playlist,
onPlay = onPlay, onPlay = onPlay,
playEnabled = playEnabled, playEnabled = playEnabled && !greyed,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
} }
private const val PILL_BG_ALPHA = 0.85f private const val PILL_BG_ALPHA = 0.85f
private const val GREYED_ALPHA = 0.45f
private fun subtitleFor(playlist: PlaylistRef): String = when { private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.trackCount > 0 -> "${playlist.trackCount} tracks" playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
@@ -1,6 +1,11 @@
package com.fabledsword.minstrel.search.data package com.fabledsword.minstrel.search.data
import com.fabledsword.minstrel.api.endpoints.SearchApi 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.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -8,17 +13,35 @@ import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/** /**
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in * Cache-first when offline. When [NetworkStatusController] reports Healthy or
* the ViewModel, not here, so the repository stays trivial. * 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 @Singleton
class SearchRepository @Inject constructor( class SearchRepository @Inject constructor(
retrofit: Retrofit, retrofit: Retrofit,
private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
) { ) {
private val api: SearchApi = retrofit.create() 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) val wire = api.search(query)
return SearchResponseRef( return SearchResponseRef(
artists = wire.artists.items.map { it.toDomain() }, artists = wire.artists.items.map { it.toDomain() },
@@ -26,4 +49,21 @@ class SearchRepository @Inject constructor(
tracks = wire.tracks.items.map { it.toDomain() }, 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.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> { is SearchResultsState.Loaded -> {
if (state.response.isEmpty) { if (state.response.isEmpty) {
CenteredHint("No matches for that query.") CenteredHint(
} else { if (state.localOnly) {
ResultsList( "No matches in your on-device library."
response = state.response, } else {
playingTrackId = playingTrackId, "No matches for that query."
onArtistClick = onArtistClick, },
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
) )
} 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 @Composable
private fun CenteredHint(text: String) { private fun CenteredHint(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { 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. */ /** Empty query — screen shows "type to search" hint. */
data object Idle : SearchResultsState data object Idle : SearchResultsState
data object Loading : 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 data class Error(val message: String) : SearchResultsState
} }
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
private suspend fun runSearch(q: String) { private suspend fun runSearch(q: String) {
internal.update { it.copy(results = SearchResultsState.Loading) } internal.update { it.copy(results = SearchResultsState.Loading) }
try { try {
val response = repository.search(q) val outcome = repository.search(q)
internal.update { it.copy(results = SearchResultsState.Loaded(response)) } internal.update {
it.copy(
results = SearchResultsState.Loaded(
response = outcome.response,
localOnly = outcome.localOnly,
),
)
}
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @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.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Wraps [content] in a Material3 PullToRefreshBox so the user can * Wraps [content] in a Material3 PullToRefreshBox so the user can
* swipe-down to trigger [onRefresh]. The wrapper manages the * swipe-down to trigger [onRefresh]. The wrapper manages the
* `isRefreshing` indicator while [onRefresh] is in flight. * `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 * [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
* indicator hides exactly when the underlying coroutine completes, * indicator hides exactly when the underlying coroutine completes,
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
fun PullToRefreshScaffold( fun PullToRefreshScaffold(
onRefresh: suspend () -> Unit, onRefresh: suspend () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
scope.launch { scope.launch {
isRefreshing = true isRefreshing = true
try { try {
netVm.recheck() // deliberate pull → re-probe the server now
onRefresh() onRefresh()
} finally { } finally {
isRefreshing = false isRefreshing = false
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
@@ -48,6 +49,7 @@ fun ShellScaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -61,6 +63,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
offlineWriteHintViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
// Consume the status-bar inset once here so the banner stack sits // Consume the status-bar inset once here so the banner stack sits
// below the status bar (mirrors Flutter's SafeArea(bottom:false)). // below the status bar (mirrors Flutter's SafeArea(bottom:false)).
// statusBarsPadding consumes the inset for descendants, so the in- // statusBarsPadding consumes the inset for descendants, so the in-
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.shared.widgets package com.fabledsword.minstrel.shared.widgets
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import 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 * 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 * 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 * cascade because the trailing slot's content is the caller's, not
* ours). * 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 @Composable
fun TrackRow( fun TrackRow(
@@ -45,15 +60,31 @@ fun TrackRow(
leading: @Composable () -> Unit = {}, leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> 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) { val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurface 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement, horizontalArrangement = horizontalArrangement,
@@ -63,7 +94,7 @@ fun TrackRow(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha), color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -71,7 +102,7 @@ fun TrackRow(
Text( Text(
text = artist, text = artist,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/** /**
* Retrofit interface for `GET /healthz` — unauthenticated health probe * Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version * returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController] * it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
* to surface the VersionTooOld banner. * for both reachability and the VersionTooOld banner.
*/ */
interface HealthzApi { interface HealthzApi {
@GET("healthz") @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.SavedStateHandle
import androidx.lifecycle.ViewModel 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 com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject 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. * [VersionResult] plus its recheck trigger for the banner button.
*/ */
@HiltViewModel @HiltViewModel
class VersionTooOldViewModel @Inject constructor( class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController, private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result val result: StateFlow<VersionResult> = controller.versionResult
fun recheck() = controller.recheck() fun recheck() = controller.recheck()
} }
@@ -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,75 @@
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())
}
private companion object {
const val DROP_THRESHOLD = 30
}
}
@@ -0,0 +1,194 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class AVTransportClientTest {
private lateinit var server: MockWebServer
private lateinit var client: AVTransportClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
}
@AfterEach
fun tearDown() {
server.shutdown()
}
@Test
fun `seek sends Target in HH MM SS format`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seek(positionMs = 65_000L)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
}
@Test
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetPositionInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Track>3</Track>
<TrackDuration>0:03:30</TrackDuration>
<TrackURI>http://x/y.mp3</TrackURI>
<RelTime>0:01:05</RelTime>
</u:GetPositionInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getPositionInfo()
assertEquals(3, info.track)
assertEquals(65_000L, info.relTimeMs)
assertEquals(210_000L, info.trackDurationMs)
assertEquals("http://x/y.mp3", info.trackUri)
}
@Test
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.PAUSED, info.state)
}
@Test
fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest {
server.enqueue(
MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetTransportInfoResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentTransportState>BUFFERING_PLAYBACK</CurrentTransportState>
<CurrentTransportStatus>OK</CurrentTransportStatus>
<CurrentSpeed>1</CurrentSpeed>
</u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>""".trimIndent(),
),
)
val info = client.getTransportInfo()
assertEquals(TransportState.UNKNOWN, info.state)
}
@Test
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
client.removeAllTracksFromQueue()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
}
val body = request.body.readUtf8()
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
}
@Test
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
server.enqueue(emptyResponse("AddURIToQueue"))
client.addURIToQueue(
uri = "http://x/y.mp3",
mime = "audio/mpeg",
title = "Song",
enqueuedURIPosition = 2,
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
"body missing EnqueuedURI: $body"
}
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
assertTrue(body.contains("&lt;dc:title&gt;Song&lt;/dc:title&gt;")) {
"title missing in DIDL: $body"
}
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
}
@Test
fun `next sends Next SOAP action`() = runTest {
server.enqueue(emptyResponse("Next"))
client.next()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `previous sends Previous SOAP action`() = runTest {
server.enqueue(emptyResponse("Previous"))
client.previous()
val request = server.takeRequest()
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) {
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
}
}
@Test
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
server.enqueue(emptyResponse("Seek"))
client.seekToTrack(trackNumber = 4)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
}
@Test
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata(
uri = "https://example.com/track.mp3",
mime = "audio/mpeg",
title = "Song",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
"CurrentURI should be sent as plain https: $body"
}
assertFalse(body.contains("x-rincon-mp3radio")) {
"x-rincon-mp3radio scheme should not appear: $body"
}
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>""".trimIndent(),
)
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.player.output.upnp
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class BareUdnTest {
@Test
fun `strips uuid prefix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
}
@Test
fun `strips MR suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips MS suffix`() {
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
}
@Test
fun `strips both uuid prefix and MR suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
}
@Test
fun `strips both uuid prefix and MS suffix`() {
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
}
@Test
fun `leaves already bare UDN unchanged`() {
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
}
@Test
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
}
}
@@ -58,6 +58,7 @@ class DeviceDescriptionTest {
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control", "http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
desc.renderingControlUrl?.toString(), desc.renderingControlUrl?.toString(),
) )
assertNull(desc.zoneGroupTopologyControlUrl)
} }
@Test @Test
@@ -81,6 +82,37 @@ class DeviceDescriptionTest {
assertNull(DeviceDescription.parse(xml, base)) assertNull(DeviceDescription.parse(xml, base))
} }
@Test
fun `parses ZoneGroupTopology control URL when present`() {
val xml = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<UDN>uuid:RINCON_XYZ</UDN>
<friendlyName>Office</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
<controlURL>/ZoneGroupTopology/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals(
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
desc.zoneGroupTopologyControlUrl?.toString(),
)
}
@Test @Test
fun `handles missing optional fields`() { fun `handles missing optional fields`() {
val xml = """ val xml = """
@@ -0,0 +1,78 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
class RenderingControlClientTest {
private lateinit var server: MockWebServer
private lateinit var client: RenderingControlClient
@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC"))
}
@AfterEach
fun tearDown() { server.shutdown() }
@Test
fun `getVolume parses CurrentVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<CurrentVolume>42</CurrentVolume>
</u:GetVolumeResponse>
</s:Body>
</s:Envelope>""".trimIndent()))
assertEquals(42, client.getVolume())
val request = server.takeRequest()
val body = request.body.readUtf8()
assertTrue(body.contains("<Channel>Master</Channel>")) { body }
assertEquals(
"\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"",
request.getHeader("SOAPACTION"),
)
}
@Test
fun `setVolume clamps and sends DesiredVolume`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(150)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>100</DesiredVolume>")) { body }
}
@Test
fun `setVolume clamps below VOLUME_MIN to zero`() = runTest {
server.enqueue(MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetVolumeResponse
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
</s:Body>
</s:Envelope>""".trimIndent()))
client.setVolume(-5)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("<DesiredVolume>0</DesiredVolume>")) { body }
}
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.player.output.upnp.sonos
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SonosTopologyTest {
@Test
fun `single zone group with one member`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_A", g.coordinatorUdn)
assertEquals("Kitchen", g.name)
assertEquals(1, g.members.size)
}
@Test
fun `stereo pair collapses to one group with two members`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
Location="http://192.168.1.11:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
Location="http://192.168.1.12:1400/xml/device_description.xml"
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
val g = groups.first()
assertEquals("RINCON_L", g.coordinatorUdn)
assertEquals("Living Room", g.name)
assertEquals(2, g.members.size)
assertNotNull(g.members[0].channelMapSet)
}
@Test
fun `multi-speaker group lists coordinator name`() {
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals("Office", groups.first().name)
}
@Test
fun `malformed xml returns empty list`() {
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
}
@Test
fun `parses inline non-escaped ZoneGroupState document`() {
// Some Sonos firmware sends the topology as nested elements with
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
// string, parse should still find the groups.
val xml = """
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
@Test
fun `parses escaped ZoneGroupState wrapped in entities`() {
val xml = """
&lt;ZoneGroupState&gt;
&lt;ZoneGroups&gt;
&lt;ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1"&gt;
&lt;ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/&gt;
&lt;/ZoneGroup&gt;
&lt;/ZoneGroups&gt;
&lt;/ZoneGroupState&gt;
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
}
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.playlists.data
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PlaylistsRepositoryCacheMergeTest {
private fun playlist(id: String, trackCount: Int) =
CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
@Test
fun `fully cached when every member track is resident`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
)
assertTrue(out.single().fullyCached)
}
@Test
fun `not fully cached when only some tracks are resident`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `not fully cached when no tracks are resident (missing count row)`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 3)),
counts = emptyList(),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `empty playlist is never fully cached`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("p", trackCount = 0)),
counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
)
assertFalse(out.single().fullyCached)
}
@Test
fun `each playlist gets its own cache verdict`() {
val out = mergePlaylistsWithCache(
rows = listOf(playlist("full", 2), playlist("partial", 2)),
counts = listOf(
PlaylistCachedCount("full", cachedCount = 2),
PlaylistCachedCount("partial", cachedCount = 1),
),
)
val byId = out.associateBy { it.id }
assertEquals(true, byId.getValue("full").fullyCached)
assertEquals(false, byId.getValue("partial").fullyCached)
}
}
+4
View File
@@ -65,6 +65,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// design at // design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md. // docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream) api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
// Extension-bearing alias so Sonos's URL probe can identify the
// audio format from the path. The {ext} param is consumed by chi
// and ignored by the handler (which keys off {id}). See task #610.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
api.Group(func(authed chi.Router) { api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool)) authed.Use(auth.RequireUser(pool))
+69 -3
View File
@@ -3,9 +3,11 @@ package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
const ( const (
@@ -23,6 +25,53 @@ type castTokenResponse struct {
Token string `json:"token"` Token string `json:"token"`
Exp int64 `json:"exp"` Exp int64 `json:"exp"`
URL string `json:"url"` URL string `json:"url"`
// MIME and Title let the client build proper DIDL-Lite metadata for
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
// passing back the track's MIME + title here lets the client populate
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
MIME string `json:"mime"`
Title string `json:"title"`
}
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
// Wraps the canonical audioContentType lookup in media.go and overrides
// the unknown-format fallback to audio/mpeg, because Sonos rejects
// DIDL-Lite with protocolInfo=application/octet-stream (the browser
// fallback) -- most Sonos firmware probes the URL anyway and recovers
// from a small MIME mismatch.
func mimeForFormat(format string) string {
mime := audioContentType(format)
if mime == "application/octet-stream" {
return "audio/mpeg"
}
return mime
}
// extForFormat maps the tracks.file_format column to a path-safe file
// extension. Sonos firmware gates duration probing on the URL path
// extension (Content-Type header alone is insufficient) -- without a
// recognizable extension, Sonos reports TrackDuration=0 and seeks
// trigger auto-advance because every position past 0 looks past-the-
// end. Defaults to "mp3" for unknown formats. See task #610.
func extForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
return "mp3"
case "flac":
return "flac"
case "aac":
return "aac"
case "m4a", "mp4":
return "m4a"
case "ogg", "vorbis":
return "ogg"
case "opus":
return "opus"
case "wav", "wave":
return "wav"
default:
return "mp3"
}
} }
// handleCastStreamToken issues a short-lived HMAC stream token for the // handleCastStreamToken issues a short-lived HMAC stream token for the
@@ -52,6 +101,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID")) writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
return return
} }
// Track lookup for the DIDL-Lite metadata the client builds for
// SetAVTransportURI. A missing track is a 404 — there's nothing to
// cast in that case.
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
if err != nil {
writeErr(w, apierror.NotFound("track"))
return
}
expSec := clampExpSeconds(req.ExpSeconds) expSec := clampExpSeconds(req.ExpSeconds)
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix() exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
token := SignStreamToken(h.streamSecret, req.TrackID, exp) token := SignStreamToken(h.streamSecret, req.TrackID, exp)
@@ -74,10 +131,19 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
if h := r.Header.Get("X-Forwarded-Host"); h != "" { if h := r.Header.Get("X-Forwarded-Host"); h != "" {
host = h host = h
} }
url := scheme + "://" + host + "/api/tracks/" + req.TrackID + // Include the file extension in the path so Sonos's URL probe sees a
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) // recognizable audio file. Without it, Sonos reports TrackDuration=0
// and seeks past 0s land "after the end" -> early track-skip.
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url}) writeJSON(w, http.StatusOK, castTokenResponse{
Token: token,
Exp: exp,
URL: url,
MIME: mimeForFormat(track.FileFormat),
Title: track.Title,
})
} }
// clampExpSeconds applies the [60, 86400] window with a 6h default for // clampExpSeconds applies the [60, 86400] window with a 6h default for
+22 -6
View File
@@ -10,15 +10,21 @@ import (
"time" "time"
) )
const testTrackUUID = "11111111-1111-1111-1111-111111111111" // nonExistentTrackUUID is used by tests that exercise paths which don't
// require the track to actually exist (auth/UUID-shape rejection).
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
func TestCastStreamToken_HappyPath(t *testing.T) { func TestCastStreamToken_HappyPath(t *testing.T) {
h, pool := testHandlers(t) h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false) user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret") h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{ body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID, TrackID: trackID,
ExpSeconds: 3600, ExpSeconds: 3600,
}) })
if err != nil { if err != nil {
@@ -44,10 +50,16 @@ func TestCastStreamToken_HappyPath(t *testing.T) {
if !strings.Contains(resp.URL, "token="+resp.Token) { if !strings.Contains(resp.URL, "token="+resp.Token) {
t.Fatalf("URL missing token query: %s", resp.URL) t.Fatalf("URL missing token query: %s", resp.URL)
} }
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") { if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
t.Fatalf("URL missing stream path: %s", resp.URL) t.Fatalf("URL missing stream path: %s", resp.URL)
} }
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) { // Stream URL must carry a file extension so Sonos's URL probe can
// identify the audio format (see task #610). Track seeded above is
// .flac via seedTrack's default file_format.
if !strings.Contains(resp.URL, "/stream.flac?") {
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
}
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
t.Fatal("returned token does not verify") t.Fatal("returned token does not verify")
} }
} }
@@ -77,7 +89,7 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
h, _ := testHandlers(t) h, _ := testHandlers(t)
h.streamSecret = []byte("cast-token-test-secret") h.streamSecret = []byte("cast-token-test-secret")
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID}) body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
@@ -96,11 +108,15 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) { func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
h, pool := testHandlers(t) h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false) user := seedUser(t, pool, "alice", "hunter2", false)
artist := seedArtist(t, pool, "Artist")
album := seedAlbum(t, pool, artist.ID, "Album", 0)
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
trackID := uuidToString(track.ID)
h.streamSecret = []byte("cast-token-test-secret") h.streamSecret = []byte("cast-token-test-secret")
// Request 1 second (below min 60), expect clamp to 60s. // Request 1 second (below min 60), expect clamp to 60s.
body, err := json.Marshal(castTokenRequest{ body, err := json.Marshal(castTokenRequest{
TrackID: testTrackUUID, TrackID: trackID,
ExpSeconds: 1, ExpSeconds: 1,
}) })
if err != nil { if err != nil {
+9
View File
@@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string {
return "/api/tracks/" + uuidToString(trackID) + "/stream" return "/api/tracks/" + uuidToString(trackID) + "/stream"
} }
// streamURLWithExt returns the extension-bearing stream URL used by UPnP
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
// audio file extension; the bare `/stream` shape reports TrackDuration=0
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
// alias for legacy / web / Subsonic clients. See task #610.
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
return streamURL(trackID) + "." + ext
}
// artistRefFrom projects a dbq.Artist into an ArtistRef without cover. // artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
// albumCount must be pre-computed by the caller. Used by code paths that // albumCount must be pre-computed by the caller. Used by code paths that
// don't have a representative-album lookup at hand (artist detail, search, // don't have a representative-album lookup at hand (artist detail, search,
+21 -25
View File
@@ -22,46 +22,42 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// resolveAlbumCoverPath returns the filesystem path to the album's cover art. // resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// It prefers an explicit cover_art_path (set by the scanner in a future // local alias so the call sites in this file read naturally.
// milestone) and falls back to a sidecar next to the first track in the
// album's directory. "" means no art was found.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string { func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" { return coverart.ResolveAlbumPath(ctx, q, album)
if _, err := os.Stat(*album.CoverArtPath); err == nil {
return *album.CoverArtPath
}
}
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
if err != nil || len(tracks) == 0 {
return ""
}
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
} }
// audioContentType maps the short file_format recorded on tracks (mp3, flac, // audioContentType maps the short file_format recorded on tracks (mp3, flac,
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header. // ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
// Unknown formats fall back to octet-stream so the browser downloads them // This is the canonical table; both the browser stream endpoint and the
// rather than attempting to decode. // UPnP cast token URL builder consult it. Unknown formats fall back to
// octet-stream so the browser downloads them rather than attempting to
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
//
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
// that have shown up in track rows. The trim+lowercase normalization makes
// the lookup permissive to whatever a scanner happened to write.
// //
// Divergences from internal/subsonic/types.go's contentTypeForFormat are // Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so // intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not // Ogg-encapsulated, so this matches real library contents), aac→audio/aac
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case // (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
// (we don't record that format). Don't "fix" these to match subsonic. // and there is no "oga" case (we don't record that format). Subsonic is a
// frozen client contract -- don't "fix" these to match it.
func audioContentType(format string) string { func audioContentType(format string) string {
switch strings.ToLower(format) { switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3": case "mp3", "mpeg":
return "audio/mpeg" return "audio/mpeg"
case "flac": case "flac":
return "audio/flac" return "audio/flac"
case "ogg", "opus": case "ogg", "opus", "vorbis":
return "audio/ogg" return "audio/ogg"
case "m4a": case "m4a", "mp4":
return "audio/mp4" return "audio/mp4"
case "aac": case "aac":
return "audio/aac" return "audio/aac"
case "wav": case "wav", "wave":
return "audio/wav" return "audio/wav"
} }
return "application/octet-stream" return "application/octet-stream"
+2 -2
View File
@@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
if t.TrackID != nil { if t.TrackID != nil {
s := uuidToString(*t.TrackID) s := uuidToString(*t.TrackID)
v.TrackID = &s v.TrackID = &s
streamURL := "/api/tracks/" + s + "/stream" url := streamURL(*t.TrackID)
v.StreamURL = &streamURL v.StreamURL = &url
} }
if t.AlbumID != nil { if t.AlbumID != nil {
s := uuidToString(*t.AlbumID) s := uuidToString(*t.AlbumID)
+24
View File
@@ -7,8 +7,11 @@
package coverart package coverart
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// SidecarNames is the lookup order for cover art living next to audio files. // SidecarNames is the lookup order for cover art living next to audio files.
@@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string {
} }
return "" return ""
} }
// ResolveAlbumPath returns the on-disk path to an album's cover image,
// preferring the explicit album.cover_art_path when set and the file
// exists, falling back to a sidecar (cover.jpg / folder.jpg) next to the
// first track in the album's directory. "" means no art was found.
//
// Shared by internal/api/media.go (browser endpoint) and
// internal/subsonic/stream.go (Subsonic endpoint); they were byte-identical
// duplicates before extraction.
func ResolveAlbumPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
if _, err := os.Stat(*album.CoverArtPath); err == nil {
return *album.CoverArtPath
}
}
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
if err != nil || len(tracks) == 0 {
return ""
}
return FindSidecar(filepath.Dir(tracks[0].FilePath))
}
+33 -6
View File
@@ -176,16 +176,43 @@ func fallbackGlyph() image.Image {
return img return img
} }
// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor. // drawScaled copies src into dst.Rect using a center-cropped "cover" fit
// stdlib lacks high-quality scaling; nearest-neighbor is fine for a // (the same model as BoxFit.cover / object-fit: cover in the clients).
// 600x600 output where each cell is 300x300 — most album covers are // Non-square sources are scaled so the *smaller* destination dimension is
// already 300-1500 pixels and the visual loss is minor. // fully filled and the larger axis is center-cropped, preserving aspect
// ratio. Without this, banner-shaped or LP-shaped album art stretches in
// the cell -- the album-coherent system playlists (new_for_you,
// first_listens) make the stretching disproportionately visible because
// fewer unique covers contribute, so each warped cell is a quarter of
// the collage rather than diluted.
//
// Scaling itself stays nearest-neighbor -- stdlib lacks high-quality
// scaling and dependency cost is unjustified for this 600x600 output.
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) { func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
srcBounds := src.Bounds() srcBounds := src.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
if srcW <= 0 || srcH <= 0 {
return
}
dstW := r.Dx()
dstH := r.Dy()
// Cover-fit: the side of src that maps to dst at the larger scale
// fully fills its axis; the other axis is center-cropped.
scaleX := float64(dstW) / float64(srcW)
scaleY := float64(dstH) / float64(srcH)
scale := scaleX
if scaleY > scale {
scale = scaleY
}
cropW := float64(dstW) / scale
cropH := float64(dstH) / scale
cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2
cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2
for y := r.Min.Y; y < r.Max.Y; y++ { for y := r.Min.Y; y < r.Max.Y; y++ {
sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH))
for x := r.Min.X; x < r.Max.X; x++ { for x := r.Min.X; x < r.Max.X; x++ {
sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx() sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW))
sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy()
dst.Set(x, y, src.At(sx, sy)) dst.Set(x, y, src.At(sx, sy))
} }
} }
+3 -14
View File
@@ -130,21 +130,10 @@ func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request
WriteFail(w, r, ErrDataNotFound, "Cover art not found") WriteFail(w, r, ErrDataNotFound, "Cover art not found")
} }
// resolveAlbumCoverPath returns the filesystem path to the album's cover art, // resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// preferring an explicit cover_art_path (set by the scanner in a future // local alias so the call sites in this file read naturally.
// milestone) and falling back to a sidecar image next to any track in the
// album directory. "" means no art was found.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string { func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" { return coverart.ResolveAlbumPath(ctx, q, album)
if _, err := os.Stat(*album.CoverArtPath); err == nil {
return *album.CoverArtPath
}
}
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
if err != nil || len(tracks) == 0 {
return ""
}
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
} }
func serveImage(w http.ResponseWriter, r *http.Request, path string) { func serveImage(w http.ResponseWriter, r *http.Request, path string) {