fix(android): debounce onReady; pre-warm artwork before playback starts

Song restart: onPageFinished can fire multiple times mid-session (hash/state
changes in WebView). Added 3s debounce so onReady is not re-delivered
spuriously while a song is playing.

Notification artwork: warmArtworkCache was called from onTimelineChanged
where player.currentMediaItemIndex hadn't updated yet (lags until prepare()),
so it warmed the wrong tracks. WebBridge.setQueue now calls
MusicService.publicWarmArtwork(startIndex) immediately after setMediaItems
and before prepare()/play(), giving the bitmap loader a head start with
the correct index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-19 20:27:39 +02:00
1 parent ac4f09c8b8
commit 494361191a
3 files changed
+33 -8

No files matched your search

@@ -30,6 +30,9 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
private var controllerFuture: ListenableFuture<MediaController>? = null private var controllerFuture: ListenableFuture<MediaController>? = null
private var mediaController: MediaController? = null private var mediaController: MediaController? = null
private var pendingOpenNowPlaying = false private var pendingOpenNowPlaying = false
// Debounce onReady: WebView can fire onPageFinished multiple times (hash changes,
// certain JS navigations). Only deliver onReady once per 3-second window.
private var lastOnReadyMs = 0L
// ─── Back-press handling ────────────────────────────────────────────────── // ─── Back-press handling ──────────────────────────────────────────────────
@@ -108,7 +111,10 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
controllerFuture = future controllerFuture = future
future.addListener({ future.addListener({
mediaController = try { future.get() } catch (_: Exception) { null } mediaController = try { future.get() } catch (_: Exception) { null }
if (mediaController != null) callJs("onReady") if (mediaController != null) {
lastOnReadyMs = System.currentTimeMillis()
callJs("onReady")
}
}, ContextCompat.getMainExecutor(this)) }, ContextCompat.getMainExecutor(this))
} }
@@ -136,11 +142,16 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
// Re-announce ready + current state after a WebView reload so JS can re-sync // Re-announce ready + current state after a WebView reload so JS can re-sync.
// Debounced: WebView can fire onPageFinished for hash/state changes mid-session.
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {
if (mediaController != null) { if (mediaController != null) {
updateStateSnapshot() val now = System.currentTimeMillis()
callJs("onReady") if (now - lastOnReadyMs > 3_000) {
lastOnReadyMs = now
updateStateSnapshot()
callJs("onReady")
}
} }
if (pendingOpenNowPlaying) { if (pendingOpenNowPlaying) {
pendingOpenNowPlaying = false pendingOpenNowPlaying = false
@@ -37,6 +37,8 @@ class MusicService : MediaSessionService() {
companion object { companion object {
// Set by MainActivity before connecting; cleared on destroy. // Set by MainActivity before connecting; cleared on destroy.
@Volatile var stateListener: StateListener? = null @Volatile var stateListener: StateListener? = null
// Service instance — lets WebBridge trigger artwork pre-warming directly.
@Volatile var instance: MusicService? = null
} }
// ─── Fields ─────────────────────────────────────────────────────────────── // ─── Fields ───────────────────────────────────────────────────────────────
@@ -61,6 +63,7 @@ class MusicService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this
val httpFactory = DefaultHttpDataSource.Factory() val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0") .setUserAgent("NovaSonic-Android/1.0")
@@ -118,6 +121,7 @@ class MusicService : MediaSessionService() {
} }
override fun onDestroy() { override fun onDestroy() {
instance = null
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
mediaSession.release() mediaSession.release()
player.release() player.release()
@@ -128,11 +132,12 @@ class MusicService : MediaSessionService() {
// Loads the next `count` tracks' artwork into the CacheBitmapLoader so that // Loads the next `count` tracks' artwork into the CacheBitmapLoader so that
// onMediaItemTransition never stalls waiting for a fresh HTTP fetch. // onMediaItemTransition never stalls waiting for a fresh HTTP fetch.
private fun warmArtworkCache(count: Int = 3) { // fromIndex: explicit start index; -1 means use player.currentMediaItemIndex.
val currentIdx = player.currentMediaItemIndex private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) {
val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex
val total = player.mediaItemCount val total = player.mediaItemCount
for (offset in 0 until count) { for (offset in 0 until count) {
val idx = currentIdx + offset val idx = startIdx + offset
if (idx >= total) break if (idx >= total) break
val item = player.getMediaItemAt(idx) val item = player.getMediaItemAt(idx)
if (item.mediaMetadata.artworkUri != null) { if (item.mediaMetadata.artworkUri != null) {
@@ -141,6 +146,11 @@ class MusicService : MediaSessionService() {
} }
} }
// Called by WebBridge right after setMediaItems so artwork fetch starts
// before prepare()/play() — avoids the race where the notification renders
// before the bitmap is in the cache.
fun publicWarmArtwork(startIndex: Int) = warmArtworkCache(count = 5, fromIndex = startIndex)
// ─── Player.Listener ───────────────────────────────────────────────────── // ─── Player.Listener ─────────────────────────────────────────────────────
private inner class PlayerListener : Player.Listener { private inner class PlayerListener : Player.Listener {
@@ -27,7 +27,11 @@ class WebBridge(private val controllerProvider: () -> MediaController?) {
if (items.isEmpty()) return if (items.isEmpty()) return
runOnMain { runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain val ctrl = controllerProvider() ?: return@runOnMain
ctrl.setMediaItems(items, startIndex.coerceAtLeast(0), 0L) val idx = startIndex.coerceAtLeast(0)
ctrl.setMediaItems(items, idx, 0L)
// Pre-warm artwork before prepare()/play() so the notification bitmap
// cache is populated before Media3 renders the first notification.
MusicService.instance?.publicWarmArtwork(idx)
ctrl.prepare() ctrl.prepare()
if (autoPlay) ctrl.play() if (autoPlay) ctrl.play()
} }