Collapses three half-systems that each answered a different connectivity question into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface.
What changed
One authority. New NetworkStatusController absorbs the former VersionCheckController (/healthz poll + version parse) and ServerHealthController (tri-state derive). Every consumer — banner, Home, Playlists list, search, OfflineGatedDataSource, TrackRow, LocalServerHealth — now reads its single state. ConnectivityObserver remains only as the thin device-link input it feeds on. A pure, unit-tested ReachabilityMachine holds the state logic.
Signal-driven, corroborated. Inputs: device-link transitions (link-return → immediate probe), an adaptive /healthz poll (calm when healthy, fast when degraded), reportSuccess()/reportFailure() from an OkHttp interceptor + the audio data source + the playback-error reporter, and recheck() from pull-to-refresh.
Success is self-proving — a good stream/2xx snaps straight to Healthy.
Failure is arbitrated — a track fast-fail triggers a /healthz probe; corroborated → ServerDown, probe-ok → stay Healthy (one bad track ≠ false offline).
Sticky banner fixed. Recovery was bound to the 5-min poll; now link-return and any successful op flip to Healthy in seconds. The banner reads the controller directly (no WhileSubscribed staleness).
New non-gating Unstable state → a mild "Reconnecting…" banner during the arbitration window; never gates playback or flips a screen offline. Plus a transient "Back online" recovery flash.
Offline playlist UX.PlaylistRef.fullyCached (DAO LEFT-JOIN over cached_playlist_tracks × audio_cache_index); tiles that can't be played offline (refreshable || !fullyCached) grey out but stay tappable; the offline Playlists row leads with the Recently-played / Liked pools, orders available-before-greyed, and drops not-yet-generated placeholders. Home + Playlists-list now derive offline from the unified controller (so they react to ServerDown, not just the device link).
Tests
New JVM unit suites run in CI: ReachabilityMachineTest (state machine), PlaylistsRepositoryCacheMergeTest (fullyCached), BuildPlaylistsRowTest (offline ordering).
Test plan (on device)
Toggle airplane mode mid-playback → banner shows, then clears within seconds on reconnect (not minutes).
A single unplayable track does not flip the app to offline.
Offline: Playlists row leads with Recently-played/Liked; un-cached/refreshable tiles are greyed but still open.
Collapses three half-systems that each answered a different connectivity question into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface.
## What changed
**One authority.** New `NetworkStatusController` absorbs the former `VersionCheckController` (`/healthz` poll + version parse) and `ServerHealthController` (tri-state derive). Every consumer — banner, Home, Playlists list, search, `OfflineGatedDataSource`, `TrackRow`, `LocalServerHealth` — now reads its single `state`. `ConnectivityObserver` remains only as the thin device-link input it feeds on. A pure, unit-tested `ReachabilityMachine` holds the state logic.
**Signal-driven, corroborated.** Inputs: device-link transitions (link-return → immediate probe), an adaptive `/healthz` poll (calm when healthy, fast when degraded), `reportSuccess()`/`reportFailure()` from an OkHttp interceptor + the audio data source + the playback-error reporter, and `recheck()` from pull-to-refresh.
- **Success is self-proving** — a good stream/2xx snaps straight to `Healthy`.
- **Failure is arbitrated** — a track fast-fail triggers a `/healthz` probe; corroborated → `ServerDown`, probe-ok → stay `Healthy` (one bad track ≠ false offline).
**Sticky banner fixed.** Recovery was bound to the 5-min poll; now link-return and any successful op flip to `Healthy` in seconds. The banner reads the controller directly (no `WhileSubscribed` staleness).
**New non-gating `Unstable` state** → a mild "Reconnecting…" banner during the arbitration window; never gates playback or flips a screen offline. Plus a transient "Back online" recovery flash.
**Offline playlist UX.** `PlaylistRef.fullyCached` (DAO LEFT-JOIN over `cached_playlist_tracks` × `audio_cache_index`); tiles that can't be played offline (`refreshable || !fullyCached`) grey out but stay tappable; the offline Playlists row leads with the Recently-played / Liked pools, orders available-before-greyed, and drops not-yet-generated placeholders. Home + Playlists-list now derive `offline` from the unified controller (so they react to `ServerDown`, not just the device link).
## Tests
New JVM unit suites run in CI: `ReachabilityMachineTest` (state machine), `PlaylistsRepositoryCacheMergeTest` (`fullyCached`), `BuildPlaylistsRowTest` (offline ordering).
## Test plan (on device)
- [ ] Toggle airplane mode mid-playback → banner shows, then clears within seconds on reconnect (not minutes).
- [ ] A single unplayable track does not flip the app to offline.
- [ ] Offline: Playlists row leads with Recently-played/Liked; un-cached/refreshable tiles are greyed but still open.
- [ ] "Back online" flash appears on recovery.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
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>
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>
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>
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>
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>
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Collapses three half-systems that each answered a different connectivity question into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface.
What changed
One authority. New
NetworkStatusControllerabsorbs the formerVersionCheckController(/healthzpoll + version parse) andServerHealthController(tri-state derive). Every consumer — banner, Home, Playlists list, search,OfflineGatedDataSource,TrackRow,LocalServerHealth— now reads its singlestate.ConnectivityObserverremains only as the thin device-link input it feeds on. A pure, unit-testedReachabilityMachineholds the state logic.Signal-driven, corroborated. Inputs: device-link transitions (link-return → immediate probe), an adaptive
/healthzpoll (calm when healthy, fast when degraded),reportSuccess()/reportFailure()from an OkHttp interceptor + the audio data source + the playback-error reporter, andrecheck()from pull-to-refresh.Healthy./healthzprobe; corroborated →ServerDown, probe-ok → stayHealthy(one bad track ≠ false offline).Sticky banner fixed. Recovery was bound to the 5-min poll; now link-return and any successful op flip to
Healthyin seconds. The banner reads the controller directly (noWhileSubscribedstaleness).New non-gating
Unstablestate → a mild "Reconnecting…" banner during the arbitration window; never gates playback or flips a screen offline. Plus a transient "Back online" recovery flash.Offline playlist UX.
PlaylistRef.fullyCached(DAO LEFT-JOIN overcached_playlist_tracks×audio_cache_index); tiles that can't be played offline (refreshable || !fullyCached) grey out but stay tappable; the offline Playlists row leads with the Recently-played / Liked pools, orders available-before-greyed, and drops not-yet-generated placeholders. Home + Playlists-list now deriveofflinefrom the unified controller (so they react toServerDown, not just the device link).Tests
New JVM unit suites run in CI:
ReachabilityMachineTest(state machine),PlaylistsRepositoryCacheMergeTest(fullyCached),BuildPlaylistsRowTest(offline ordering).Test plan (on device)
🤖 Generated with Claude Code