Commit Graph

436 Commits

Author SHA1 Message Date
bvandeusen 1cc9553d1f fix(android): move "You might like" directly under the system-playlists row
android / Build + lint + test (push) Successful in 3m37s
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>
2026-06-11 22:50:02 -04:00
bvandeusen c2f9bbaff4 fix(android): suppress detekt TooManyFunctions on HomeRepository
android / Build + lint + test (push) Successful in 3m32s
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>
2026-06-11 22:19:13 -04:00
bvandeusen abc225e12a feat(android): "You might like" Home rows (#790 client slice)
android / Build + lint + test (push) Failing after 1m17s
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>
2026-06-11 22:16:32 -04:00
bvandeusen 4ecb1680bf fix(android): drop second loop jump in supersededLikeToggleIds (detekt)
android / Build + lint + test (push) Successful in 3m51s
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>
2026-06-11 12:11:54 -04:00
bvandeusen d2a22e49e3 fix: harden offline/playback recording (contract-audit follow-ups)
test-go / test (push) Successful in 42s
android / Build + lint + test (push) Failing after 1m27s
test-go / integration (push) Successful in 4m42s
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>
2026-06-11 12:05:35 -04:00
bvandeusen 653ed95f14 fix(android): always serialize event type so plays aren't dropped
android / Build + lint + test (push) Successful in 5m21s
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>
2026-06-11 00:01:13 -04:00
bvandeusen dabca3ad24 fix(android): stop single SOAP failure from dropping Sonos to local
android / Build + lint + test (push) Successful in 3m44s
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>
2026-06-07 10:54:14 -04:00
bvandeusen 8f29cc7414 feat: poll in-flight requests on native + refresh after submit (#369)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m37s
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.
2026-06-06 23:18:44 -04:00
bvandeusen c8b21aa76e fix(android): grid items() resolution on artist detail (drop bad alias)
android / Build + lint + test (push) Successful in 3m35s
2026-06-06 22:56:58 -04:00
bvandeusen 483804fc9e fix(android): extract ArtistSuccessBody to satisfy detekt LongMethod
android / Build + lint + test (push) Failing after 2m56s
2026-06-06 22:48:45 -04:00
bvandeusen 12a8cfccb5 feat(android): similar-artists strip + top-tracks on artist detail
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 40s
android / Build + lint + test (push) Failing after 1m16s
test-go / integration (push) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:39:54 -04:00
bvandeusen 06e155abb6 fix(android): rename IdleRevertDecision.kt to satisfy detekt MatchingDeclarationName
android / Build + lint + test (push) Successful in 4m11s
2026-06-06 17:36:07 -04:00
bvandeusen 6b11208e5a perf(android): fail-fast connect timeout for UPnP transport SOAP
android / Build + lint + test (push) Failing after 1m8s
2026-06-06 17:30:28 -04:00
bvandeusen 41da012494 fix(android): failed UPnP play reverts to phone and resumes locally 2026-06-06 17:29:58 -04:00
bvandeusen 8a3104443c feat(android): record play-intent on remote play/pause 2026-06-06 17:29:36 -04:00
bvandeusen 5f5ab69da6 feat(android): track UPnP play-intent surviving SOAP errors 2026-06-06 17:29:19 -04:00
bvandeusen 5386ae870f feat(android): revert UPnP output to phone after 5-min idle 2026-06-06 17:28:32 -04:00
bvandeusen d7b011e52f feat(android): pure idle-revert decision for UPnP output 2026-06-06 17:27:52 -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 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 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 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 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 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 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