fix: cover art on auto-advance + native back-button overlay routing

Issue 1 (MusicService): wrap DefaultHttpDataSource in CacheBitmapLoader so
Media3 notification artwork is fetched once and served from memory on every
subsequent auto-advance. warmArtworkCache() pre-loads current+next 3 tracks
on both onMediaItemTransition and onTimelineChanged (queue replace via setQueue).
Adds explicit media3-datasource dependency.

Issue 2 (back button): replace deprecated onKeyDown with OnBackPressedCallback.
WebBridge.setOverlayState(bool) tracks whether JS has an overlay open;
consumeBackPress() is called first — if true, MainActivity delegates to
window.NSPlayer.handleBack() which closes the topmost layer. Falls back to
webView.goBack() then moveTaskToBack(true) so the MusicService binding survives.
JS side: _syncOverlayState() + MutationObserver keep the native flag current;
handleBack covers NP overlay, queue panel, action sheet, all modals, sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-09 11:11:09 +02:00
1 parent 76ecfe2268
commit cf2921c56f
7 files changed
+178 -10

No files matched your search

+1
View File
@@ -44,5 +44,6 @@ dependencies {
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.common)
implementation(libs.media3.datasource)
implementation(libs.kotlinx.coroutines.android)
}
@@ -8,12 +8,12 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.KeyEvent
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@@ -30,6 +30,30 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
private var controllerFuture: ListenableFuture<MediaController>? = null
private var mediaController: MediaController? = null
// ─── Back-press handling ──────────────────────────────────────────────────
// Always enabled — the callback itself decides whether to handle the event.
private val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webBridge.consumeBackPress()) {
// An overlay is open in JS — ask it to close the topmost layer.
webView.evaluateJavascript(
"if(window.NSPlayer&&window.NSPlayer.handleBack)window.NSPlayer.handleBack()",
null
)
return
}
if (webView.canGoBack()) {
webView.goBack()
return
}
// Nothing left to pop — send the task to background.
// Using moveTaskToBack instead of finish() so the Activity stays alive
// and the MusicService binding is preserved; resuming is instant.
moveTaskToBack(true)
}
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
@@ -40,6 +64,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
webBridge = WebBridge { mediaController }
configureWebView()
requestNotificationPermission()
onBackPressedDispatcher.addCallback(this, backCallback)
// Explicitly start the service before binding so Android 14 lets it
// promote to foreground (bindService alone doesn't satisfy the
// "started from foreground" requirement for foreground services).
@@ -60,13 +85,6 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
super.onDestroy()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
webView.goBack(); return true
}
return super.onKeyDown(keyCode, event)
}
// ─── Service connection ───────────────────────────────────────────────────
private fun connectToService() {
@@ -8,9 +8,17 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.MoreExecutors
import java.util.concurrent.Executors
class MusicService : MediaSessionService() {
@@ -34,6 +42,7 @@ class MusicService : MediaSessionService() {
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession
private lateinit var bitmapLoader: CacheBitmapLoader
private val handler = Handler(Looper.getMainLooper())
// 250 ms position ticker — only runs while actually playing
@@ -52,18 +61,39 @@ class MusicService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
val dataSourceFactory = DefaultDataSource.Factory(this, httpFactory)
// CacheBitmapLoader caches fetched bitmaps in-memory so that
// the notification never has to re-fetch artwork on auto-advance.
val executor = Executors.newSingleThreadExecutor()
bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader(
MoreExecutors.listeningDecorator(executor),
dataSourceFactory
)
)
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
.build()
mediaSession = MediaSession.Builder(this, player).build()
mediaSession = MediaSession.Builder(this, player)
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
.build()
player.addListener(PlayerListener())
}
@@ -82,6 +112,23 @@ class MusicService : MediaSessionService() {
super.onDestroy()
}
// ─── Artwork pre-warming ──────────────────────────────────────────────────
// 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
val total = player.mediaItemCount
for (offset in 0 until count) {
val idx = currentIdx + offset
if (idx >= total) break
val item = player.getMediaItemAt(idx)
if (item.mediaMetadata.artworkUri != null) {
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
}
}
}
// ─── Player.Listener ─────────────────────────────────────────────────────
private inner class PlayerListener : Player.Listener {
@@ -120,6 +167,16 @@ class MusicService : MediaSessionService() {
mediaItem?.mediaId ?: "",
reasonStr
)
// Pre-warm artwork for current + next tracks so notification
// never shows a blank cover on auto-advance.
warmArtworkCache()
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
// Queue was replaced (setQueue / addToQueue from JS) — warm cache from new start.
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
warmArtworkCache()
}
}
override fun onPlayerError(error: PlaybackException) {
@@ -130,6 +130,17 @@ class WebBridge(private val controllerProvider: () -> MediaController?) {
@JavascriptInterface
fun getState(): String = stateSnapshot
// ─── Overlay state (for back-button routing) ──────────────────────────────
// Written by WebView thread; read by UI thread — must be @Volatile.
@Volatile private var hasOpenOverlay = false
@JavascriptInterface
fun setOverlayState(active: Boolean) { hasOpenOverlay = active }
// Called by MainActivity on back press; returns true if JS owns the event.
fun consumeBackPress(): Boolean = hasOpenOverlay
// ─── Internal ─────────────────────────────────────────────────────────────
private fun runOnMain(block: () -> Unit) {
+1
View File
@@ -14,6 +14,7 @@ material = { group = "com.google.android.material", name = "material", version.r
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]