diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt index 8a34e89..f745638 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -30,6 +30,9 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { private var controllerFuture: ListenableFuture? = null private var mediaController: MediaController? = null 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 ────────────────────────────────────────────────── @@ -108,7 +111,10 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { controllerFuture = future future.addListener({ mediaController = try { future.get() } catch (_: Exception) { null } - if (mediaController != null) callJs("onReady") + if (mediaController != null) { + lastOnReadyMs = System.currentTimeMillis() + callJs("onReady") + } }, ContextCompat.getMainExecutor(this)) } @@ -136,11 +142,16 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { webView.webViewClient = object : WebViewClient() { 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) { if (mediaController != null) { - updateStateSnapshot() - callJs("onReady") + val now = System.currentTimeMillis() + if (now - lastOnReadyMs > 3_000) { + lastOnReadyMs = now + updateStateSnapshot() + callJs("onReady") + } } if (pendingOpenNowPlaying) { pendingOpenNowPlaying = false 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 fb366cb..9869c99 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -37,6 +37,8 @@ class MusicService : MediaSessionService() { companion object { // Set by MainActivity before connecting; cleared on destroy. @Volatile var stateListener: StateListener? = null + // Service instance — lets WebBridge trigger artwork pre-warming directly. + @Volatile var instance: MusicService? = null } // ─── Fields ─────────────────────────────────────────────────────────────── @@ -61,6 +63,7 @@ class MusicService : MediaSessionService() { override fun onCreate() { super.onCreate() + instance = this val httpFactory = DefaultHttpDataSource.Factory() .setUserAgent("NovaSonic-Android/1.0") @@ -118,6 +121,7 @@ class MusicService : MediaSessionService() { } override fun onDestroy() { + instance = null handler.removeCallbacksAndMessages(null) mediaSession.release() player.release() @@ -128,11 +132,12 @@ class MusicService : MediaSessionService() { // Loads the next `count` tracks' artwork into the CacheBitmapLoader so that // onMediaItemTransition never stalls waiting for a fresh HTTP fetch. - private fun warmArtworkCache(count: Int = 3) { - val currentIdx = player.currentMediaItemIndex + // fromIndex: explicit start index; -1 means use player.currentMediaItemIndex. + private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) { + val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex val total = player.mediaItemCount for (offset in 0 until count) { - val idx = currentIdx + offset + val idx = startIdx + offset if (idx >= total) break val item = player.getMediaItemAt(idx) 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 ───────────────────────────────────────────────────── private inner class PlayerListener : Player.Listener { diff --git a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt index 6582278..de0cefd 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt @@ -27,7 +27,11 @@ class WebBridge(private val controllerProvider: () -> MediaController?) { if (items.isEmpty()) 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() if (autoPlay) ctrl.play() }