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.exoplayer)
implementation(libs.media3.session) implementation(libs.media3.session)
implementation(libs.media3.common) implementation(libs.media3.common)
implementation(libs.media3.datasource)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
} }
@@ -8,12 +8,12 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.KeyEvent
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -30,6 +30,30 @@ 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
// ─── 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 ──────────────────────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -40,6 +64,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
webBridge = WebBridge { mediaController } webBridge = WebBridge { mediaController }
configureWebView() configureWebView()
requestNotificationPermission() requestNotificationPermission()
onBackPressedDispatcher.addCallback(this, backCallback)
// Explicitly start the service before binding so Android 14 lets it // Explicitly start the service before binding so Android 14 lets it
// promote to foreground (bindService alone doesn't satisfy the // promote to foreground (bindService alone doesn't satisfy the
// "started from foreground" requirement for foreground services). // "started from foreground" requirement for foreground services).
@@ -60,13 +85,6 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
super.onDestroy() 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 ─────────────────────────────────────────────────── // ─── Service connection ───────────────────────────────────────────────────
private fun connectToService() { private fun connectToService() {
@@ -8,9 +8,17 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player 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.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.MoreExecutors
import java.util.concurrent.Executors
class MusicService : MediaSessionService() { class MusicService : MediaSessionService() {
@@ -34,6 +42,7 @@ class MusicService : MediaSessionService() {
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession private lateinit var mediaSession: MediaSession
private lateinit var bitmapLoader: CacheBitmapLoader
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
// 250 ms position ticker — only runs while actually playing // 250 ms position ticker — only runs while actually playing
@@ -52,18 +61,39 @@ class MusicService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.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() val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.build() .build()
player = ExoPlayer.Builder(this) player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug .setHandleAudioBecomingNoisy(true) // pause on headphone unplug
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze .setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
.build() .build()
mediaSession = MediaSession.Builder(this, player).build() mediaSession = MediaSession.Builder(this, player)
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
.build()
player.addListener(PlayerListener()) player.addListener(PlayerListener())
} }
@@ -82,6 +112,23 @@ class MusicService : MediaSessionService() {
super.onDestroy() 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 ───────────────────────────────────────────────────── // ─── Player.Listener ─────────────────────────────────────────────────────
private inner class PlayerListener : Player.Listener { private inner class PlayerListener : Player.Listener {
@@ -120,6 +167,16 @@ class MusicService : MediaSessionService() {
mediaItem?.mediaId ?: "", mediaItem?.mediaId ?: "",
reasonStr 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) { override fun onPlayerError(error: PlaybackException) {
@@ -130,6 +130,17 @@ class WebBridge(private val controllerProvider: () -> MediaController?) {
@JavascriptInterface @JavascriptInterface
fun getState(): String = stateSnapshot 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 ───────────────────────────────────────────────────────────── // ─── Internal ─────────────────────────────────────────────────────────────
private fun runOnMain(block: () -> Unit) { 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-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", 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-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" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins] [plugins]
+3 -1
View File
@@ -2,7 +2,7 @@ import { S } from './state.js';
import { esc } from './utils.js'; import { esc } from './utils.js';
import { coverUrl } from './api.js'; import { coverUrl } from './api.js';
// Circular imports — safe (only used inside function bodies) // 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 { navigate } from './views.js';
import { openPlModal, showSongInfo } from './ui.js'; import { openPlModal, showSongInfo } from './ui.js';
@@ -13,6 +13,7 @@ export function openNowPlaying() {
overlay.classList.add('open'); overlay.classList.add('open');
if(S.npQueueMode) switchNpTab('player'); if(S.npQueueMode) switchNpTab('player');
syncNowPlaying(); syncNowPlaying();
_syncOverlayState();
if(!overlay._swipeAttached) { if(!overlay._swipeAttached) {
overlay._swipeAttached = true; overlay._swipeAttached = true;
let startY=0, startT=0, dragging=false; let startY=0, startT=0, dragging=false;
@@ -42,6 +43,7 @@ export function closeNowPlaying() {
const overlay = document.getElementById('np-overlay'); const overlay = document.getElementById('np-overlay');
overlay.style.transform = ''; overlay.style.transform = '';
overlay.classList.remove('open'); overlay.classList.remove('open');
_syncOverlayState();
} }
export function syncNowPlaying() { export function syncNowPlaying() {
+78
View File
@@ -710,6 +710,7 @@ export function toggleQueue() {
S.queueOpen = !S.queueOpen; S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen); document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
if(S.queueOpen) renderQueue(); if(S.queueOpen) renderQueue();
_syncOverlayState();
} }
// ─── DOWNLOAD ───────────────────────────────────────────────────────────── // ─── DOWNLOAD ─────────────────────────────────────────────────────────────
@@ -767,6 +768,83 @@ export function updatePreviewProgress() {
// ─── RESTORE LAST PLAYED ────────────────────────────────────────────────── // ─── 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() { export function _restoreLastPlayed() {
try { try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');