From 52079536b673d712f359b9031640c6ade2945411 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 20 May 2026 23:38:25 +0200 Subject: [PATCH] fix: eliminate notification/playerbar song desync on Android Two-pronged fix for the state drift between the system notification (ground truth) and the WebView player bar: JS (player.js): onTrackChanged now reconciles the incoming index with a synchronous NSBridge.getState() call before applying it. Stale callbacks from earlier rapid skips immediately correct themselves to the live settled index instead of overwriting the correct state. Kotlin (MusicService.kt): debounce JS-initiated transitions (seek / setQueue) by 120 ms so rapid skip sequences produce one onTrackChanged notification at the final index rather than one per skip step. Auto-advance transitions are still fired immediately since JS doesn't initiate those. Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/com/novasonic/app/MusicService.kt | 35 +++++++++++++++---- src/player.js | 13 +++++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 9869c99..069e59e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -155,6 +155,10 @@ class MusicService : MediaSessionService() { private inner class PlayerListener : Player.Listener { + // Debounce JS-initiated transitions (seek / queue-replace) so rapid skips + // produce one onTrackChanged notification with the final settled state. + private var _pendingTrackChanged: Runnable? = null + override fun onIsPlayingChanged(isPlaying: Boolean) { val state = player.playbackState stateListener?.onPlaybackStateChanged( @@ -184,14 +188,31 @@ class MusicService : MediaSessionService() { Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue" else -> "seek" } - stateListener?.onTrackChanged( - player.currentMediaItemIndex, - mediaItem?.mediaId ?: "", - reasonStr - ) - // Pre-warm artwork for current + next tracks so notification - // never shows a blank cover on auto-advance. + // Always warm artwork immediately so the notification never shows a blank cover. warmArtworkCache() + + // Cancel any pending debounced dispatch from a previous transition. + _pendingTrackChanged?.let { handler.removeCallbacks(it) } + _pendingTrackChanged = null + + val dispatch = Runnable { + _pendingTrackChanged = null + stateListener?.onTrackChanged( + player.currentMediaItemIndex, + player.currentMediaItem?.mediaId ?: "", + reasonStr + ) + } + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { + // Native auto-advance: JS didn't initiate this, notify immediately. + dispatch.run() + } else { + // JS-initiated (seek or setQueue): debounce 120 ms so rapid skips + // coalesce into one callback with the final settled index. + _pendingTrackChanged = dispatch + handler.postDelayed(dispatch, 120) + } } override fun onTimelineChanged(timeline: Timeline, reason: Int) { diff --git a/src/player.js b/src/player.js index 74f5e0d..00c066a 100644 --- a/src/player.js +++ b/src/player.js @@ -143,9 +143,16 @@ window.NSPlayer = { try { const p = JSON.parse(payloadJson); if (typeof p.index !== 'number') return; - if (p.index < 0 || p.index >= S.queue.length) return; - const indexChanged = (p.index !== S.qIdx); - S.qIdx = p.index; + // Reconcile with the live native state so stale out-of-order callbacks + // (e.g. from rapid skips) don't overwrite the already-correct index. + let idx = p.index; + try { + const live = JSON.parse(window.NSBridge.getState() || '{}'); + if (typeof live.currentIndex === 'number') idx = live.currentIndex; + } catch {} + if (idx < 0 || idx >= S.queue.length) return; + const indexChanged = (idx !== S.qIdx); + S.qIdx = idx; const s = S.queue[S.qIdx]; if (!s) return; updatePlayerUI(s, true);