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]
+3 -1
View File
@@ -2,7 +2,7 @@ import { S } from './state.js';
import { esc } from './utils.js';
import { coverUrl } from './api.js';
// Circular imports — safe (only used inside function bodies)
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar } from './player.js';
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState } from './player.js';
import { navigate } from './views.js';
import { openPlModal, showSongInfo } from './ui.js';
@@ -13,6 +13,7 @@ export function openNowPlaying() {
overlay.classList.add('open');
if(S.npQueueMode) switchNpTab('player');
syncNowPlaying();
_syncOverlayState();
if(!overlay._swipeAttached) {
overlay._swipeAttached = true;
let startY=0, startT=0, dragging=false;
@@ -42,6 +43,7 @@ export function closeNowPlaying() {
const overlay = document.getElementById('np-overlay');
overlay.style.transform = '';
overlay.classList.remove('open');
_syncOverlayState();
}
export function syncNowPlaying() {
+78
View File
@@ -710,6 +710,7 @@ export function toggleQueue() {
S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
if(S.queueOpen) renderQueue();
_syncOverlayState();
}
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
@@ -767,6 +768,83 @@ export function updatePreviewProgress() {
// ─── RESTORE LAST PLAYED ──────────────────────────────────────────────────
// ─── BACK-BUTTON / OVERLAY STATE SYNC ────────────────────────────────────
// Native back button: when JS has an overlay open, Android delegates the event
// to window.NSPlayer.handleBack() instead of minimising the app.
function _hasAnyOverlayOpen() {
if (S.npOpen || S.queueOpen) return true;
if (document.getElementById('np-sheet')?.classList.contains('open')) return true;
if (document.getElementById('ctx-menu')?.style.display === 'block') return true;
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) return true;
return ['settings-modal','pl-modal','info-modal','dl-modal','edit-pl-modal','confirm-modal','notif-panel']
.some(id => document.getElementById(id)?.classList.contains('open'));
}
export function _syncOverlayState() {
if (!HAS_BRIDGE) return;
try { window.NSBridge.setOverlayState(_hasAnyOverlayOpen()); } catch {}
}
window.NSPlayer.handleBack = function() {
// Close overlays from topmost layer downward.
if (document.getElementById('np-sheet')?.classList.contains('open')) {
window.closeNpMenu?.(); _syncOverlayState(); return;
}
const ctxMenu = document.getElementById('ctx-menu');
if (ctxMenu?.style.display === 'block') {
ctxMenu.style.display = 'none'; ctxMenu.innerHTML = '';
_syncOverlayState(); return;
}
if (S.npOpen) {
window.closeNowPlaying?.(); _syncOverlayState(); return;
}
if (S.queueOpen) {
toggleQueue(); _syncOverlayState(); return;
}
// Generic modals
const modalMap = {
'settings-modal': 'closeSettings',
'pl-modal': 'closePlModal',
'info-modal': 'closeInfoModal',
'dl-modal': 'closeDlModal',
'edit-pl-modal': 'closeEditPlModal',
'confirm-modal': '_confirmCancel',
};
for (const [id, fn] of Object.entries(modalMap)) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) {
if (window[fn]) window[fn](); else el.classList.remove('open');
_syncOverlayState(); return;
}
}
// Notif panel (generic fallback)
const notifPanel = document.getElementById('notif-panel');
if (notifPanel?.classList.contains('open')) {
notifPanel.classList.remove('open'); _syncOverlayState(); return;
}
// Sidebar
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) {
window.closeSidebar?.(); _syncOverlayState(); return;
}
};
// MutationObserver keeps hasOpenOverlay in sync whenever the DOM changes class
// attributes (e.g. any overlay gains/loses the 'open' class).
if (HAS_BRIDGE) {
const _startOverlayObserver = () => {
if (!document.body) return;
new MutationObserver(_syncOverlayState).observe(document.body, {
attributes: true, attributeFilter: ['class'], subtree: true,
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _startOverlayObserver);
} else {
_startOverlayObserver();
}
}
export function _restoreLastPlayed() {
try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');