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:
1 parent
76ecfe2268
commit
cf2921c56f
7 files changed
+178
-10
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user