From cdfc79e6abaa8c33dd61ec87ab880df8c3ab629b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 30 Jun 2026 18:48:35 -0400 Subject: [PATCH] feat(android/diagnostics): per-skip track-change event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeats are 45s apart and missed a rapid skip burst (local_index 16→22 in one gap). Add a 'playback' track_change event emitted on each queue-index / current-track change, snapshotting local vs Sonos index+position + server_health + upnp_loading + route — so a transient skip-induced desync is captured at the instant it happens. (uiState is a conflated StateFlow, so a very rapid burst may coalesce intermediate indices; we still get the boundaries + the snapshot.) Refs Scribe M9 (#119), task #1210. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW --- .../diagnostics/DiagnosticsReporter.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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.