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 d804b348..88897a9d 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 @@ -102,6 +102,7 @@ class DiagnosticsReporter @Inject constructor( launch { collectServerHealth() } launch { collectUpnpDrops() } launch { collectPlayerState() } + launch { collectTrackChanges() } launch { collectRoutes() } launch { heartbeatLoop() } } @@ -162,6 +163,30 @@ class DiagnosticsReporter @Inject constructor( } } + // Emit a snapshot at each track/queue-index change — captures a + // skip-induced local↔Sonos desync at the INSTANT it happens, which the + // 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a + // very rapid skip burst may coalesce intermediate indices (we still get + // the boundaries + the local-vs-Sonos snapshot). + private suspend fun collectTrackChanges() { + playerController.uiState + .map { it.queueIndex to it.currentTrack?.id } + .distinctUntilChanged() + .collect { (index, _) -> + val ui = playerController.uiState.value + record("playback", buildJsonObject { + put("event", "track_change") + put("local_index", index) + put("local_pos_ms", ui.positionMs) + put("sonos_track", remoteState.trackNumber) + put("sonos_pos_ms", remoteState.positionMs) + put("upnp_loading", ui.isUpnpLoading) + put("server_health", networkStatus.state.value.name) + put("route", outputPicker.routesState.value.current.name) + }) + } + } + private suspend fun collectRoutes() { // 'playback' — route changes happen for all outputs. This only ever // logs the ACTIVE route (routesState.current), so no "connected" flag.