fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening #98

Merged
bvandeusen merged 2 commits from dev into main 2026-06-12 21:25:25 -04:00

2 Commits

Author SHA1 Message Date
bvandeusen fd7d6dac4c feat(android): adopt a running UPnP session + tighten cast recovery/anti-stickiness
android / Build + lint + test (push) Successful in 3m53s
Three related improvements to UPnP/Sonos session handling, on top of the
WiFi-lock + drop-suppression fixes.

1. Adopt a running session instead of clear+reload (the headline).
   Selecting a renderer always did removeAllTracksFromQueue + full reload --
   a jarring restart if the speaker was already playing our queue (e.g. after
   the phone got disconnected but the autonomous Sonos kept going). selectUpnp
   now probes the renderer first; if it's mid-playback on the same track id at
   the same queue index, we ATTACH in place: sync the local cursor to its
   position, wire ActiveUpnpHolder, start polling -- no clear, no reload, and
   skip/seek immediately drive its live queue. Falls through to clear+reload
   when it isn't our queue. New PlayerController.moveCursorTo aligns the local
   cursor without auto-playing.

2. Discovery expiry + selection revert (anti-stickiness). upsertRoute only
   ever added, so a powered-off renderer lingered in the picker forever and
   could pin a stale selection. Stamp lastSeen per route; after the picker's
   active M-SEARCH scan, prune routes that didn't re-announce. A collector
   reverts the selection to the phone when the selected route leaves discovery
   while we're not actively casting -- so a later play never targets a ghost.
   Pruning is tied to picker-open scans only (no background timer -> no row
   flicker).

3. Reconcile immediately on network recovery. When NetworkStatus flips back to
   Healthy while a route is active, nudge an immediate poll instead of waiting
   up to POLL_INTERVAL_MS -- the held session re-confirms the renderer in one
   round-trip.

Verified (read-only): the tap-play-onto-dead-route fallback still fires when
the phone's network is Healthy (the poll-loop drop path is unchanged for that
case); the drop-suppression gate only holds during phone-side outages, where a
local fallback couldn't play either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:20:26 -04:00
bvandeusen d6290a3ef0 fix(android): don't drop Sonos (or blast local audio) when the phone's own network is down
android / Build + lint + test (push) Successful in 3m43s
Observed on device: casting to Sonos on battery + screen locked, a
transient ~67s reachability gap (NetworkStatus -> ServerDown while WiFi
itself stayed associated) starved the 1 Hz poll past DROP_THRESHOLD. The
poll loop then dropped the route and fell back to the local player, which
honored the play-intent -- so the phone suddenly started playing the song
out loud locally while the Sonos was still happily streaming it.

A poll failure during a phone-side network outage means "we can't see the
renderer right now," not "the renderer died": a UPnP renderer streams
autonomously and keeps playing, and the local player we'd fall back to
can't reach the server either. Dropping is strictly worse than waiting.

Gate the drop on NetworkStatusController: only drop when the phone's
network is Healthy (renderer genuinely unreachable on an otherwise-fine
link). While Unstable/ServerDown/Offline, hold the route, keep polling,
and clear the failure streak so recovery re-evaluates from scratch rather
than re-dropping on the first post-recovery hiccup. The poll reconciles to
the renderer's real (advanced) position once the network returns.

Complements the CastNetworkLock fix: the lock reduces how often these gaps
happen; this stops a gap that does happen from punishing the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:48:40 -04:00