Operator expected the row immediately beneath the system-generated playlists
section, not below Rediscover. Reorders the section call (presentation only —
no logic/state change) and updates the doc comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two new you-might-like observe accessors pushed HomeRepository from 11 to
13 functions, tripping detekt's per-class default. It's accessor density (one
observe method per Home row) on a thin pass-through repository, not complexity —
suppressed with a one-line rationale per the project convention. ktlint already
passed; this was the only detekt finding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the server's you_might_like_albums / you_might_like_artists sections
(daily-built, cold-start gated, taste-aware) on the Home screen, mirroring the
Rediscover block.
- HomeIndexWire: two new slices, defaulted to emptyList() so decode is safe
against older servers that don't emit them (Class-B discipline).
- HomeRepository: two section constants + observeYouMightLikeAlbums/Artists
(reusing the existing album/artist hydration helpers) + refreshIndex now
replaces both sections and pre-warms their artists. No Room schema change —
cached_home_index stores the section string verbatim.
- HomeScreen: HomeSections gains the two fields (+ isAllEmpty); the ViewModel
combine is split (core 5 → +2 you-might-like → +playlists) to stay within the
coroutines 5-arity limit; a YouMightLikeBlock + youMightLikeSection render an
albums-then-artists block below Rediscover, with a "still learning your taste"
empty state for the cold-start/gated case.
Server side already shipped in v2026.06.11; this makes it visible on device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LoopWithTooManyJumpStatements — replace the two continues with a
filtered sequence + a single null guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of every Android↔server connection point (2026-06-11) cleared the
silent-contract class that caused the events `type` bug, but surfaced a
cluster of offline/playback-robustness defects. Fixes:
1. Playback double-count on background. PlayEventsReporter no longer
enqueues a partial play_offline + leaves the live row open on every
screen-lock. closeCurrent() now routes by whether the server has an
open row: close-by-id (durable PLAY_ENDED on failure) when it does,
offline only when no row exists, and a no-op while a play_started is
in flight (the server auto-closes that orphan). onStop only durably
closes a *paused* play — a still-playing one is left to the live path
under the foreground service. Adds the PLAY_ENDED mutation kind.
2. Replayer poison rows. MutationReplayer now classifies each replay as
SENT / DROP / RETRY: permanent 4xx (and corrupt payloads) are dropped
instead of retried forever; 408/429/5xx/transport still retry.
3. Offline-play / close-by-id idempotency (server). RecordOfflinePlay
dedups on (user, track, started_at); RecordPlayEnded skips a second
skip_events insert when re-closing an already-ended row. Makes the
at-least-once replay safe against lost-response duplicates.
4. Like-toggle collapse. Replayer drops like-toggles superseded by a
later toggle for the same entity, so partial-failure + differential
retry can't invert the final like state.
5. Connectivity-return trigger. MutationReplayer + SyncController now
also drain/sync when NetworkStatusController recovers to Healthy, so
an offline→online transition mid-session doesn't wait for a cold
start. SyncController.syncSafe gains a single-in-flight mutex.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native client's /api/events requests omitted the `type`
discriminator entirely. The app's Json is configured with
encodeDefaults=false (AppModule), so a `type` left at its data-class
default ("play_started" etc.) is never written to the wire. The server
multiplexes on `type` and returns 400 "unknown event type" for an
empty one, which PlayEventsReporter's catch swallows — and the
play_started path has no offline fallback, so the play is lost with no
trace.
Net effect: EVERY native Android play event (started/ended/skipped/
offline) has 400'd since this code was written. Listening History only
ever populated from the Flutter/web clients; as usage moved to the
native app, History went sparse. Confirmed live in the server access
log: POST /api/events -> 400 on every play, while reads 200.
Force the discriminator onto the wire with
@EncodeDefault(Mode.ALWAYS) on each request type's `type` field.
Surgical (vs flipping encodeDefaults globally), and idiomatic for a
constant-valued discriminator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the phone was locked, a transport command (lock-screen/Bluetooth/
watch media button, or queue-resync play) issued during a WiFi power-save
stall would hit the 2s connect timeout and throw. handleSoapFailure then
cancelled the poll loop and fired onDrop on that single failure -- showing
"Disconnected from <Sonos>" and reverting to local playback, even though
the renderer was perfectly reachable. This bypassed the poll loop's
deliberate 30-consecutive-failure tolerance (DROP_THRESHOLD, bumped from 3
precisely for screen-off WiFi sleep / Doze).
Make the 1 Hz poll loop the sole drop arbiter:
- Transport SOAP commands retry transient IO failures (retryTransientIo:
3 attempts, 400ms backoff) so a brief WiFi stall lands the command once
WiFi wakes instead of abandoning it. A SoapFaultException (renderer
answered, rejected the action) is not retried -- the device is alive.
- handleTransportFailure no longer cancels the poll loop or fires onDrop;
it logs and nudges an immediate poll so the UI reconciles to Sonos's
actual state. If the renderer is truly gone, the poll loop trips the
drop on its own via DROP_THRESHOLD.
Extract retryTransientIo as an internal top-level fn + unit test covering
first-success, retry-then-succeed, exhaust-and-rethrow, and no-retry-on-
SoapFault. Refresh now-stale drop-heuristic comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native RequestsViewModel gains poll-while-approved (silent reloads, paused
when nothing in-flight) for parity with the web auto-poll, and the SSE
collector now reloads silently instead of flashing the loading spinner.
Web discover submit invalidates qk.myRequests() so a new request appears
on /requests immediately.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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
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.
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.
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.
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.
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.
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.
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.
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.