From ba382aa6d91bcb7ff8abb07bd07af5874ffcd1cf Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 18 May 2026 22:12:29 +0200 Subject: [PATCH] fix: cover art caching, skip desync, notification tap opens now playing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../kotlin/com/novasonic/app/MainActivity.kt | 18 ++++++++++++++++ .../kotlin/com/novasonic/app/MusicService.kt | 12 +++++++++++ src/api.js | 12 ++++++++++- src/player.js | 21 +++++++++++++++---- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt index d994565..8a34e89 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -29,6 +29,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { private lateinit var webBridge: WebBridge private var controllerFuture: ListenableFuture? = null private var mediaController: MediaController? = null + private var pendingOpenNowPlaying = false // ─── Back-press handling ────────────────────────────────────────────────── @@ -73,6 +74,19 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" } 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() } @@ -128,6 +142,10 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { updateStateSnapshot() callJs("onReady") } + if (pendingOpenNowPlaying) { + pendingOpenNowPlaying = false + webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) } + } } } webView.webChromeClient = WebChromeClient() diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 1a3fe22..fb366cb 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -1,5 +1,6 @@ package com.novasonic.app +import android.app.PendingIntent import android.content.Intent import android.os.Handler import android.os.Looper @@ -91,8 +92,19 @@ class MusicService : MediaSessionService() { .setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze .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) .setBitmapLoader(bitmapLoader) // use cached loader for notification artwork + .setSessionActivity(notifTapPending) .build() player.addListener(PlayerListener()) diff --git a/src/api.js b/src/api.js index 89fccb4..0f2b344 100644 --- a/src/api.js +++ b/src/api.js @@ -16,6 +16,11 @@ export async function api(method, extra='') { return r; } 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) { try { const r = await api('getLyricsBySongId', `&id=${songId}`); @@ -23,7 +28,12 @@ export async function getLyricsBySongId(songId) { } catch { return []; } } 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 ─── export async function ndAuth() { diff --git a/src/player.js b/src/player.js index 535ba0f..6274762 100644 --- a/src/player.js +++ b/src/player.js @@ -496,13 +496,20 @@ export function prevSong() { _clearPreload(); if (HAS_BRIDGE) { _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) { S.qIdx--; const s = S.queue[S.qIdx]; 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; } if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; } @@ -518,8 +525,13 @@ export function nextSong() { S.qIdx = ni; const s = S.queue[S.qIdx]; 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(); return; } @@ -677,7 +689,8 @@ function _initBarScrub(bar, fill, onMove) { _touchStartX = e.touches[0].clientX; _touchMoved = false; e.stopPropagation(); - }, {passive:true}); + e.preventDefault(); // block synthetic mousedown/click that would trigger scrub on plain tap + }); bar.addEventListener('touchmove', e => { const dx = Math.abs(e.touches[0].clientX - _touchStartX); if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); }