The five sub-phases of #618 land together as a single coherent offline experience.
Phase 1 — ServerHealthController tri-state (Healthy / Offline / ServerDown) composed from ConnectivityObserver.online + VersionCheckController.reachable. Banner copy distinguishes "no device network" from "server unreachable" so the user can act on it. VersionCheckController gains a reachable: StateFlow<Boolean> driven by the existing 5-min /healthz poll.
Phase 3 — OfflineGatedDataSource wraps the OkHttpDataSource upstream of CacheDataSource. On a cache miss while ServerHealth is non-Healthy, it throws OfflineException immediately instead of letting OkHttp wait out a multi-second timeout (which then surfaced as a silent decode failure). Cached playback unaffected — CacheDataSource only consults upstream on miss.
Phase 2 — SearchRepository falls back to Room LIKE queries over cached_artists / cached_albums / cached_tracks when offline. New searchByName / searchByTitle DAOs; the screen shows a one-line "on-device only" hint above the results.
Phase 4 — TrackRow consumes new LocalServerHealth CompositionLocal. Tracks not in LocalCachedTrackIds dim to 0.4 alpha when offline; tap fires a Toast ("Not downloaded — connect to play") instead of attempting playback. Trailing slot (kebab, like, playlist-add) stays interactive so write affordances can queue.
Phase 5 — MutationQueue emits a userEnqueueHints: SharedFlow<String> on user-driven enqueues (like toggle, playlist append, request create/cancel, quarantine flag/unflag). ShellScaffold subscribes via OfflineWriteHintViewModel and surfaces "Saved — will sync when online" via its snackbar host. Background enqueues (play-events, playback-error reports) don't emit — those fire from non-foreground paths.
Android — Sonos polish
MediaSession picks up UPnP state — MinstrelForwardingPlayer directly invokes Player.Listener.onIsPlayingChanged / onPlaybackStateChanged / onMediaItemTransition when remoteState mutates, so the notification card and lock-screen heart track the actual Sonos transport.
UPnP extend throttle — 50ms delay between consecutive AddURIToQueue SOAP calls in extendQueueOnSonos, preventing the burst-rate-limit that Sonos was hitting on queue-extension flushes.
Server / CI
Unify stream URL builders + MIME tables + cover-path helper — closes the divergent-provider audit from 2026-06-04. streamURL helper used everywhere; audioContentType is the canonical file_format → MIME lookup; coverart.ResolveAlbumPath extracted; api/media.go and subsonic/stream.go both delegate.
Collage center-crop — drawScaled no longer nearest-neighbor stretches non-square covers to fill 300×300 cells. Now preserves aspect + center-crops, matching every other UI surface. Existing collages re-render on the next 03:00 system-playlist rebuild.
CI integration test 2.4× speedup — ALTER SYSTEM SET fsync = off / synchronous_commit = off / full_page_writes = off on the throwaway CI Postgres before migrations. Wall-clock 10m34s → 4m27s; internal/api 7.8×.
Test plan
Toggle airplane mode → banner says "No connection — check Wi-Fi or mobile data."
Take server down (or block its DNS) with device online → banner says "Server unreachable — your cached content is still available."
Search while offline → on-device matches show under a one-line hint; no spinner timeout.
Browse a long album list while offline → uncached rows dim; tap one fires Toast.
Like a track / add to playlist while offline → shell snackbar confirms "Saved — will sync when online".
Sonos: notification heart and play/pause icon track actual transport state through skips and pauses.
System-playlist collages render without stretched non-square covers (visible after next 03:00 rebuild).
10 commits since v2026.06.04.
## Android #618 — offline-mode UX composite (Phases 1, 3, 2, 4, 5)
The five sub-phases of #618 land together as a single coherent offline experience.
- **Phase 1** — `ServerHealthController` tri-state (Healthy / Offline / ServerDown) composed from `ConnectivityObserver.online` + `VersionCheckController.reachable`. Banner copy distinguishes "no device network" from "server unreachable" so the user can act on it. `VersionCheckController` gains a `reachable: StateFlow<Boolean>` driven by the existing 5-min `/healthz` poll.
- **Phase 3** — `OfflineGatedDataSource` wraps the `OkHttpDataSource` upstream of `CacheDataSource`. On a cache miss while ServerHealth is non-Healthy, it throws `OfflineException` immediately instead of letting OkHttp wait out a multi-second timeout (which then surfaced as a silent decode failure). Cached playback unaffected — `CacheDataSource` only consults upstream on miss.
- **Phase 2** — `SearchRepository` falls back to Room LIKE queries over `cached_artists` / `cached_albums` / `cached_tracks` when offline. New `searchByName` / `searchByTitle` DAOs; the screen shows a one-line "on-device only" hint above the results.
- **Phase 4** — `TrackRow` consumes new `LocalServerHealth` CompositionLocal. Tracks not in `LocalCachedTrackIds` dim to 0.4 alpha when offline; tap fires a Toast ("Not downloaded — connect to play") instead of attempting playback. Trailing slot (kebab, like, playlist-add) stays interactive so write affordances can queue.
- **Phase 5** — `MutationQueue` emits a `userEnqueueHints: SharedFlow<String>` on user-driven enqueues (like toggle, playlist append, request create/cancel, quarantine flag/unflag). ShellScaffold subscribes via `OfflineWriteHintViewModel` and surfaces "Saved — will sync when online" via its snackbar host. Background enqueues (play-events, playback-error reports) don't emit — those fire from non-foreground paths.
## Android — Sonos polish
- `MediaSession picks up UPnP state` — `MinstrelForwardingPlayer` directly invokes `Player.Listener.onIsPlayingChanged` / `onPlaybackStateChanged` / `onMediaItemTransition` when `remoteState` mutates, so the notification card and lock-screen heart track the actual Sonos transport.
- `UPnP extend throttle` — 50ms delay between consecutive `AddURIToQueue` SOAP calls in `extendQueueOnSonos`, preventing the burst-rate-limit that Sonos was hitting on queue-extension flushes.
## Server / CI
- `Unify stream URL builders + MIME tables + cover-path helper` — closes the divergent-provider audit from 2026-06-04. `streamURL` helper used everywhere; `audioContentType` is the canonical `file_format → MIME` lookup; `coverart.ResolveAlbumPath` extracted; `api/media.go` and `subsonic/stream.go` both delegate.
- `Collage center-crop` — `drawScaled` no longer nearest-neighbor stretches non-square covers to fill 300×300 cells. Now preserves aspect + center-crops, matching every other UI surface. Existing collages re-render on the next 03:00 system-playlist rebuild.
- `CI integration test 2.4× speedup` — `ALTER SYSTEM SET fsync = off / synchronous_commit = off / full_page_writes = off` on the throwaway CI Postgres before migrations. Wall-clock 10m34s → 4m27s; `internal/api` 7.8×.
## Test plan
- [ ] Toggle airplane mode → banner says "No connection — check Wi-Fi or mobile data."
- [ ] Take server down (or block its DNS) with device online → banner says "Server unreachable — your cached content is still available."
- [ ] Search while offline → on-device matches show under a one-line hint; no spinner timeout.
- [ ] Browse a long album list while offline → uncached rows dim; tap one fires Toast.
- [ ] Like a track / add to playlist while offline → shell snackbar confirms "Saved — will sync when online".
- [ ] Sonos: notification heart and play/pause icon track actual transport state through skips and pauses.
- [ ] System-playlist collages render without stretched non-square covers (visible after next 03:00 rebuild).
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.
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.
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.
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.
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.
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.
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.
10 commits since v2026.06.04.
Android #618 — offline-mode UX composite (Phases 1, 3, 2, 4, 5)
The five sub-phases of #618 land together as a single coherent offline experience.
ServerHealthControllertri-state (Healthy / Offline / ServerDown) composed fromConnectivityObserver.online+VersionCheckController.reachable. Banner copy distinguishes "no device network" from "server unreachable" so the user can act on it.VersionCheckControllergains areachable: StateFlow<Boolean>driven by the existing 5-min/healthzpoll.OfflineGatedDataSourcewraps theOkHttpDataSourceupstream ofCacheDataSource. On a cache miss while ServerHealth is non-Healthy, it throwsOfflineExceptionimmediately instead of letting OkHttp wait out a multi-second timeout (which then surfaced as a silent decode failure). Cached playback unaffected —CacheDataSourceonly consults upstream on miss.SearchRepositoryfalls back to Room LIKE queries overcached_artists/cached_albums/cached_trackswhen offline. NewsearchByName/searchByTitleDAOs; the screen shows a one-line "on-device only" hint above the results.TrackRowconsumes newLocalServerHealthCompositionLocal. Tracks not inLocalCachedTrackIdsdim to 0.4 alpha when offline; tap fires a Toast ("Not downloaded — connect to play") instead of attempting playback. Trailing slot (kebab, like, playlist-add) stays interactive so write affordances can queue.MutationQueueemits auserEnqueueHints: SharedFlow<String>on user-driven enqueues (like toggle, playlist append, request create/cancel, quarantine flag/unflag). ShellScaffold subscribes viaOfflineWriteHintViewModeland surfaces "Saved — will sync when online" via its snackbar host. Background enqueues (play-events, playback-error reports) don't emit — those fire from non-foreground paths.Android — Sonos polish
MediaSession picks up UPnP state—MinstrelForwardingPlayerdirectly invokesPlayer.Listener.onIsPlayingChanged/onPlaybackStateChanged/onMediaItemTransitionwhenremoteStatemutates, so the notification card and lock-screen heart track the actual Sonos transport.UPnP extend throttle— 50ms delay between consecutiveAddURIToQueueSOAP calls inextendQueueOnSonos, preventing the burst-rate-limit that Sonos was hitting on queue-extension flushes.Server / CI
Unify stream URL builders + MIME tables + cover-path helper— closes the divergent-provider audit from 2026-06-04.streamURLhelper used everywhere;audioContentTypeis the canonicalfile_format → MIMElookup;coverart.ResolveAlbumPathextracted;api/media.goandsubsonic/stream.goboth delegate.Collage center-crop—drawScaledno longer nearest-neighbor stretches non-square covers to fill 300×300 cells. Now preserves aspect + center-crops, matching every other UI surface. Existing collages re-render on the next 03:00 system-playlist rebuild.CI integration test 2.4× speedup—ALTER SYSTEM SET fsync = off / synchronous_commit = off / full_page_writes = offon the throwaway CI Postgres before migrations. Wall-clock 10m34s → 4m27s;internal/api7.8×.Test plan