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:
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user