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.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) {
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user