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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-20 23:38:25 +02:00
1 parent 0874fd4f9f
commit 52079536b6
2 files changed
+38 -10

No files matched your search

@@ -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) {