Android #618 offline-mode UX + Sonos polish + server DRY #81

Merged
bvandeusen merged 7 commits from dev into main 2026-06-04 12:53:59 -04:00
Owner

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 1ServerHealthController 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 3OfflineGatedDataSource 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 2SearchRepository 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 4TrackRow 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 5MutationQueue 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 stateMinstrelForwardingPlayer 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-cropdrawScaled 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× speedupALTER 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).
bvandeusen added 7 commits 2026-06-04 12:53:41 -04:00
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 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.
feat(android): cache-only audio path when ServerHealth != Healthy
android / Build + lint + test (push) Successful in 4m58s
fced6b681e
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.
feat(android): #618 Phase 4 — row-level offline-unavailable affordance
android / Build + lint + test (push) Has been cancelled
1daea79f64
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.
feat(android): #618 Phase 5 — confirm offline writes via shell snackbar
android / Build + lint + test (push) Successful in 3m58s
1e17eeda72
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.
bvandeusen merged commit 7cf04fe24b into main 2026-06-04 12:53:59 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#81