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)