From 392454b2495321613862e2497e45258e6f4e9e0a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 30 Jun 2026 19:15:12 -0400 Subject: [PATCH] feat(android/diagnostics): track-identity enrichment + zero stale Sonos state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeric indices wobble across re-casts (offset +1↔0 seen during output toggling), making "same track?" ambiguous. Enrich both the track_change event and the heartbeat with local_track_id (TrackRef.id) and sonos_uri (RemotePlayerState.currentTrackUri — the URL the speaker is actually streaming), so a desync is unambiguous. Also fixes the cast→phone stale-state pollution (#1211): sonos_* is now zeroed unless a remote route is active, via a shared putSonos() helper — so a just-ended cast's RemotePlayerState can't masquerade as live Sonos data in the diagnostics. Refs Scribe M9 (#119), tasks #1210 #1211. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW --- .../diagnostics/DiagnosticsReporter.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt index 88897a9d..142f5a2b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/diagnostics/DiagnosticsReporter.kt @@ -174,12 +174,16 @@ class DiagnosticsReporter @Inject constructor( .distinctUntilChanged() .collect { (index, _) -> val ui = playerController.uiState.value + val casting = outputPicker.routesState.value.current.protocol != + OutputRoute.Protocol.SYSTEM record("playback", buildJsonObject { put("event", "track_change") put("local_index", index) + // Track IDENTITY, not just index — indices wobble across + // re-casts, so the id + Sonos URI make a desync unambiguous. + put("local_track_id", ui.currentTrack?.id ?: "") put("local_pos_ms", ui.positionMs) - put("sonos_track", remoteState.trackNumber) - put("sonos_pos_ms", remoteState.positionMs) + putSonos(this, casting) put("upnp_loading", ui.isUpnpLoading) put("server_health", networkStatus.state.value.name) put("route", outputPicker.routesState.value.current.name) @@ -214,13 +218,11 @@ class DiagnosticsReporter @Inject constructor( put("is_playing", ui.isPlaying) put("source", ui.currentSource ?: "") put("local_index", ui.queueIndex) + put("local_track_id", ui.currentTrack?.id ?: "") put("local_pos_ms", ui.positionMs) put("route", route.name) put("route_protocol", route.protocol.name) - // UPnP/Sonos remote-vs-local — the desync signal. - put("sonos_track", remoteState.trackNumber) - put("sonos_pos_ms", remoteState.positionMs) - put("sonos_playing", remoteState.isPlaying) + putSonos(this, routeActive) addPowerFields(this) }) } @@ -285,6 +287,18 @@ class DiagnosticsReporter @Inject constructor( addPowerFields(this) } + // Sonos/UPnP remote-vs-local desync fields. Only meaningful while a + // remote route is active; zeroed otherwise so a stale RemotePlayerState + // from a just-ended cast can't masquerade as live Sonos data (the + // cast→phone handoff artifact). currentTrackUri is the desync ground + // truth — it carries the track the speaker is actually streaming. + private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) { + builder.put("sonos_track", if (casting) remoteState.trackNumber else 0) + builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0) + builder.put("sonos_playing", casting && remoteState.isPlaying) + if (casting) builder.put("sonos_uri", remoteState.currentTrackUri) + } + private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) { val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager builder.put("doze", pm.isDeviceIdleMode)