diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f2b2cf..1a17af8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) } 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 99eb5cd..4e554c4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -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? = 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() { 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 975a27d..cb5a1f3 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt index 5b52412..6582278 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt @@ -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) { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 36298e4..4213ae8 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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] diff --git a/src/nowplaying.js b/src/nowplaying.js index 831a19c..1615545 100644 --- a/src/nowplaying.js +++ b/src/nowplaying.js @@ -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() { diff --git a/src/player.js b/src/player.js index 090d429..86f1689 100644 --- a/src/player.js +++ b/src/player.js @@ -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');