fix: cover art caching, skip desync, notification tap opens now playing

- api.js: cache coverUrl() per id+size so WebView HTTP cache can work;
  previously each call generated a new random salt/token → unique URL →
  no cache hits → images re-fetched on every render

- player.js: replace _nsBridge('next')/'prev' with explicit seekToIndex
  in nextSong/prevSong so native always jumps to the exact same index JS
  computed; 'next'/'prev' relied on native's internal pointer being in sync
  which caused 'UI shows new song but audio plays old one' when they drifted

- MusicService: set setSessionActivity PendingIntent on MediaSession so
  tapping the playback notification opens the app

- MainActivity: handle open_now_playing extra in onCreate (cold start)
  and onNewIntent (app already running) to show the Now Playing screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-18 22:12:29 +02:00
1 parent 66099c12f0
commit ba382aa6d9
4 files changed
+58 -5

No files matched your search

@@ -29,6 +29,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
private lateinit var webBridge: WebBridge private lateinit var webBridge: WebBridge
private var controllerFuture: ListenableFuture<MediaController>? = null private var controllerFuture: ListenableFuture<MediaController>? = null
private var mediaController: MediaController? = null private var mediaController: MediaController? = null
private var pendingOpenNowPlaying = false
// ─── Back-press handling ────────────────────────────────────────────────── // ─── Back-press handling ──────────────────────────────────────────────────
@@ -73,6 +74,19 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" } val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" }
webView.loadUrl(url) webView.loadUrl(url)
// Notification tap while app was not running — defer until page is loaded.
if (intent?.getBooleanExtra("open_now_playing", false) == true) {
pendingOpenNowPlaying = true
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Notification tap while app is already running — WebView is ready immediately.
if (intent.getBooleanExtra("open_now_playing", false)) {
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
}
} }
override fun onResume() { super.onResume(); webView.onResume() } override fun onResume() { super.onResume(); webView.onResume() }
@@ -128,6 +142,10 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
updateStateSnapshot() updateStateSnapshot()
callJs("onReady") callJs("onReady")
} }
if (pendingOpenNowPlaying) {
pendingOpenNowPlaying = false
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
}
} }
} }
webView.webChromeClient = WebChromeClient() webView.webChromeClient = WebChromeClient()
@@ -1,5 +1,6 @@
package com.novasonic.app package com.novasonic.app
import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -91,8 +92,19 @@ class MusicService : MediaSessionService() {
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze .setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
.build() .build()
// Tapping the notification opens the app directly to the Now Playing screen.
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
putExtra("open_now_playing", true)
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val notifTapPending = PendingIntent.getActivity(
this, 0, notifTapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
mediaSession = MediaSession.Builder(this, player) mediaSession = MediaSession.Builder(this, player)
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork .setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
.setSessionActivity(notifTapPending)
.build() .build()
player.addListener(PlayerListener()) player.addListener(PlayerListener())
+11 -1
View File
@@ -16,6 +16,11 @@ export async function api(method, extra='') {
return r; return r;
} }
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); } export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); }
// Stable per-session cache so the same id+size always produces the same URL.
// Consistent URLs let WebView's HTTP cache work — without this every render
// generates a new token (random salt) and the image is re-fetched every time.
const _coverUrlCache = new Map();
export async function getLyricsBySongId(songId) { export async function getLyricsBySongId(songId) {
try { try {
const r = await api('getLyricsBySongId', `&id=${songId}`); const r = await api('getLyricsBySongId', `&id=${songId}`);
@@ -23,7 +28,12 @@ export async function getLyricsBySongId(songId) {
} catch { return []; } } catch { return []; }
} }
export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); } export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
export function coverUrl(id, size=300) { return id ? apiUrl('getCoverArt', `&id=${id}&size=${size}`) : null; } export function coverUrl(id, size=300) {
if (!id) return null;
const key = `${id}:${size}`;
if (!_coverUrlCache.has(key)) _coverUrlCache.set(key, apiUrl('getCoverArt', `&id=${id}&size=${size}`));
return _coverUrlCache.get(key);
}
// ─── NAVIDROME REST AUTH ─── // ─── NAVIDROME REST AUTH ───
export async function ndAuth() { export async function ndAuth() {
+17 -4
View File
@@ -496,13 +496,20 @@ export function prevSong() {
_clearPreload(); _clearPreload();
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
_setSkipping(); _setSkipping();
if (_lastPositionMs > 3000) { _resetProgressUI(); _nsBridge('prev'); return; } if (_lastPositionMs > 3000) {
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
_resetProgressUI();
_nsBridge('seekTo', 0);
return;
}
if (S.qIdx > 0) { if (S.qIdx > 0) {
S.qIdx--; S.qIdx--;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); } if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); }
// Explicit index so native doesn't have to agree on what "previous" means.
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
} }
_nsBridge('prev');
return; return;
} }
if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; } if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; }
@@ -518,8 +525,13 @@ export function nextSong() {
S.qIdx = ni; S.qIdx = ni;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); } if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); }
// Explicit index prevents desync when JS and native queue pointers diverge.
_nsBridge('seekToIndex', ni, 0);
_nsBridge('play');
} else {
// Shuffle mode: native picks the shuffled-next item; onTrackChanged will confirm the index.
_nsBridge('next');
} }
_nsBridge('next');
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
return; return;
} }
@@ -677,7 +689,8 @@ function _initBarScrub(bar, fill, onMove) {
_touchStartX = e.touches[0].clientX; _touchStartX = e.touches[0].clientX;
_touchMoved = false; _touchMoved = false;
e.stopPropagation(); e.stopPropagation();
}, {passive:true}); e.preventDefault(); // block synthetic mousedown/click that would trigger scrub on plain tap
});
bar.addEventListener('touchmove', e => { bar.addEventListener('touchmove', e => {
const dx = Math.abs(e.touches[0].clientX - _touchStartX); const dx = Math.abs(e.touches[0].clientX - _touchStartX);
if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); } if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); }