diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9f2b2cf --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.novasonic.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.novasonic.app" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"") + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.media3.exoplayer) + implementation(libs.media3.session) + implementation(libs.media3.common) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1a74ddb --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# Keep @JavascriptInterface methods — called by WebView via reflection, stripped otherwise. +-keepclassmembers class com.novasonic.app.WebBridge { + @android.webkit.JavascriptInterface ; +} + +# Keep Media3 session / exoplayer classes used by reflection / service binding. +-keep class androidx.media3.** { *; } +-keep class com.google.android.exoplayer2.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..034821e --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/bridge-test.html b/android/app/src/main/assets/bridge-test.html new file mode 100644 index 0000000..eaf0365 --- /dev/null +++ b/android/app/src/main/assets/bridge-test.html @@ -0,0 +1,189 @@ + + + + + + NSBridge Test + + + + +

NovaSonic Bridge Test

+

Load this page as a local asset to verify the NSBridge surface before connecting the real PWA.

+ +
Waiting for bridge...\n
+ +

Queue

+ + + + + +

Transport

+ + + + + + + + +

Settings

+ + + + + + + + + +

State

+ + +

NSPlayer callbacks (incoming from native)

+

These fire automatically when native state changes.

+ + + + diff --git a/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt b/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt new file mode 100644 index 0000000..d46f975 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt @@ -0,0 +1,53 @@ +package com.novasonic.app + +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import org.json.JSONArray +import org.json.JSONObject + +data class Track( + val id: String, + val url: String, + val title: String, + val artist: String, + val album: String?, + val artworkUrl: String?, + val durationMs: Long +) { + fun toMediaItem(): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .apply { + if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl)) + if (durationMs > 0) setDurationMs(durationMs) + } + .build() + return MediaItem.Builder() + .setMediaId(id) + .setUri(url) + .setMediaMetadata(metadata) + .build() + } + + companion object { + fun fromJson(obj: JSONObject) = Track( + id = obj.optString("id", ""), + url = obj.optString("url", ""), + title = obj.optString("title", ""), + artist = obj.optString("artist", ""), + album = obj.optString("album").takeIf { it.isNotEmpty() }, + artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() }, + durationMs = obj.optLong("durationMs", 0) + ) + } +} + +fun parseTrackList(json: String): List = try { + val arr = JSONArray(json) + List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) } +} catch (_: Exception) { + emptyList() +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt new file mode 100644 index 0000000..69e6b76 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -0,0 +1,188 @@ +package com.novasonic.app + +import android.Manifest +import android.content.ComponentName +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +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.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture +import org.json.JSONObject + +class MainActivity : AppCompatActivity(), MusicService.StateListener { + + private lateinit var webView: WebView + private lateinit var webBridge: WebBridge + private var controllerFuture: ListenableFuture? = null + private var mediaController: MediaController? = null + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + webView = findViewById(R.id.web_view) + + webBridge = WebBridge { mediaController } + configureWebView() + requestNotificationPermission() + connectToService() + + val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" } + webView.loadUrl(url) + } + + override fun onResume() { super.onResume(); webView.onResume() } + override fun onPause() { webView.onPause(); super.onPause() } + + override fun onDestroy() { + MusicService.stateListener = null + controllerFuture?.let { MediaController.releaseFuture(it) } + webView.destroy() + 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() { + MusicService.stateListener = this + val token = SessionToken(this, ComponentName(this, MusicService::class.java)) + val future = MediaController.Builder(this, token).buildAsync() + controllerFuture = future + future.addListener({ + mediaController = try { future.get() } catch (_: Exception) { null } + if (mediaController != null) callJs("onReady") + }, ContextCompat.getMainExecutor(this)) + } + + // ─── WebView configuration ──────────────────────────────────────────────── + + private fun configureWebView() { + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + mediaPlaybackRequiresUserGesture = false + allowFileAccess = true // needed for bridge-test.html asset + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + useWideViewPort = true + loadWithOverviewMode = true + userAgentString = userAgentString.replace("wv", "") + " NovaSonicApp/1.0" + } + + // New full bridge — JS calls window.NSBridge.* + webView.addJavascriptInterface(webBridge, "NSBridge") + + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false + + // Re-announce ready + current state after a WebView reload so JS can re-sync + override fun onPageFinished(view: WebView, url: String) { + if (mediaController != null) { + updateStateSnapshot() + callJs("onReady") + } + } + } + webView.webChromeClient = WebChromeClient() + } + + // ─── MusicService.StateListener → push state to JS ─────────────────────── + + override fun onReady() = callJs("onReady") + + override fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) { + val json = """{"isPlaying":$isPlaying,"isBuffering":$isBuffering,"isLoading":$isLoading}""" + updateStateSnapshot() + callJs("onPlaybackStateChanged", json) + } + + override fun onTrackChanged(index: Int, trackId: String, reason: String) { + val json = JSONObject().apply { + put("index", index) + put("trackId", trackId) + put("reason", reason) + }.toString() + updateStateSnapshot() + callJs("onTrackChanged", json) + } + + override fun onPositionUpdate(positionMs: Long, durationMs: Long) = + callJs("onPositionUpdate", positionMs, durationMs) + + override fun onQueueEnded() = callJs("onQueueEnded") + + override fun onError(message: String) = callJs("onError", message) + + // ─── JS bridge helpers ──────────────────────────────────────────────────── + + /** + * Calls window.NSPlayer.() safely on the UI thread. + * Strings are JSON-escaped and quoted; numbers/booleans are passed as-is. + */ + fun callJs(method: String, vararg args: Any?) { + val argsJs = args.joinToString(",") { arg -> + when (arg) { + null -> "null" + is Boolean -> arg.toString() + is Number -> arg.toString() + is String -> "\"${escapeJs(arg)}\"" + else -> "\"${escapeJs(arg.toString())}\"" + } + } + val js = "if(window.NSPlayer&&window.NSPlayer.$method)window.NSPlayer.$method($argsJs)" + webView.post { webView.evaluateJavascript(js, null) } + } + + private fun escapeJs(s: String) = s + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "") + + private fun updateStateSnapshot() { + val ctrl = mediaController ?: return + val json = JSONObject().apply { + put("isPlaying", ctrl.isPlaying) + put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING) + put("currentIndex", ctrl.currentMediaItemIndex) + put("positionMs", ctrl.currentPosition) + put("queueLength", ctrl.mediaItemCount) + }.toString() + webBridge.updateSnapshot(json) + } + + // ─── Permissions ────────────────────────────────────────────────────────── + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 42 + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt b/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt new file mode 100644 index 0000000..d946399 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt @@ -0,0 +1,4 @@ +package com.novasonic.app + +// Replaced by MusicService.kt (Media3 MediaSessionService + ExoPlayer). +// Kept as an empty file so git history is preserved. diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt new file mode 100644 index 0000000..975a27d --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -0,0 +1,129 @@ +package com.novasonic.app + +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +class MusicService : MediaSessionService() { + + // ─── State callback interface (consumed by MainActivity) ───────────────── + + interface StateListener { + fun onReady() + fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) + fun onTrackChanged(index: Int, trackId: String, reason: String) + fun onPositionUpdate(positionMs: Long, durationMs: Long) + fun onQueueEnded() + fun onError(message: String) + } + + companion object { + // Set by MainActivity before connecting; cleared on destroy. + @Volatile var stateListener: StateListener? = null + } + + // ─── Fields ─────────────────────────────────────────────────────────────── + + private lateinit var player: ExoPlayer + private lateinit var mediaSession: MediaSession + private val handler = Handler(Looper.getMainLooper()) + + // 250 ms position ticker — only runs while actually playing + private val positionTicker = object : Runnable { + override fun run() { + if (player.isPlaying) { + val dur = player.duration.let { if (it == C.TIME_UNSET) 0L else it } + stateListener?.onPositionUpdate(player.currentPosition, dur) + handler.postDelayed(this, 250) + } + } + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + override fun onCreate() { + super.onCreate() + + val audioAttributes = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + + player = ExoPlayer.Builder(this) + .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() + + player.addListener(PlayerListener()) + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + override fun onTaskRemoved(rootIntent: Intent?) { + // Keep playing when swiped from recents; only stop if idle/empty + if (!player.playWhenReady || player.mediaItemCount == 0) stopSelf() + } + + override fun onDestroy() { + handler.removeCallbacksAndMessages(null) + mediaSession.release() + player.release() + super.onDestroy() + } + + // ─── Player.Listener ───────────────────────────────────────────────────── + + private inner class PlayerListener : Player.Listener { + + override fun onIsPlayingChanged(isPlaying: Boolean) { + val state = player.playbackState + stateListener?.onPlaybackStateChanged( + isPlaying, + isBuffering = state == Player.STATE_BUFFERING, + isLoading = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING + ) + if (isPlaying) handler.post(positionTicker) + else handler.removeCallbacks(positionTicker) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + val isPlaying = player.isPlaying + stateListener?.onPlaybackStateChanged( + isPlaying, + isBuffering = playbackState == Player.STATE_BUFFERING, + isLoading = playbackState == Player.STATE_IDLE + ) + // STATE_ENDED fires when the last item finishes with no repeat + if (playbackState == Player.STATE_ENDED) stateListener?.onQueueEnded() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + val reasonStr = when (reason) { + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto" + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek" + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue" + else -> "seek" + } + stateListener?.onTrackChanged( + player.currentMediaItemIndex, + mediaItem?.mediaId ?: "", + reasonStr + ) + } + + override fun onPlayerError(error: PlaybackException) { + stateListener?.onError(error.message ?: "Playback error") + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt b/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt new file mode 100644 index 0000000..2eed7d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt @@ -0,0 +1,4 @@ +package com.novasonic.app + +// Replaced by Media3-managed notifications (MusicService.kt). +// Notification actions are now handled natively by Media3 MediaSession. diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt new file mode 100644 index 0000000..3cb3bf1 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt @@ -0,0 +1,9 @@ +package com.novasonic.app + +/** + * Legacy singleton — no longer used. Kept to avoid breaking any stale git references. + * State is now pushed directly from MusicService.StateListener via MainActivity.callJs(). + */ +object NovaSonicBridge { + @Volatile var jsRunner: ((String) -> Unit)? = null +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt new file mode 100644 index 0000000..653cffb --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt @@ -0,0 +1,4 @@ +package com.novasonic.app + +// Replaced by WebBridge.kt (window.NSBridge). +// Kept as an empty file so git history is preserved. diff --git a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt new file mode 100644 index 0000000..5b52412 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt @@ -0,0 +1,138 @@ +package com.novasonic.app + +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import org.json.JSONObject + +/** + * Exposed to the WebView as `window.NSBridge`. + * All @JavascriptInterface methods are called on a WebView background thread — every + * MediaController call is posted to the main looper before execution. + */ +class WebBridge(private val controllerProvider: () -> MediaController?) { + + // Snapshot of current playback state; read from getState() on any thread. + @Volatile private var stateSnapshot = "{}" + + fun updateSnapshot(snapshot: String) { stateSnapshot = snapshot } + + // ─── Queue management ───────────────────────────────────────────────────── + + @JavascriptInterface + fun setQueue(queueJson: String, startIndex: Int, autoPlay: Boolean) { + val items = parseTrackList(queueJson).map { it.toMediaItem() } + if (items.isEmpty()) return + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + ctrl.setMediaItems(items, startIndex.coerceAtLeast(0), 0L) + ctrl.prepare() + if (autoPlay) ctrl.play() + } + } + + @JavascriptInterface + fun addToQueue(trackJson: String, position: Int) { + val track = try { Track.fromJson(JSONObject(trackJson)) } catch (_: Exception) { return } + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + val item = track.toMediaItem() + if (position < 0 || position >= ctrl.mediaItemCount) ctrl.addMediaItem(item) + else ctrl.addMediaItem(position, item) + } + } + + @JavascriptInterface + fun removeFromQueue(index: Int) { + runOnMain { controllerProvider()?.removeMediaItem(index) } + } + + @JavascriptInterface + fun clearQueue() { + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + ctrl.stop() + ctrl.clearMediaItems() + } + } + + // ─── Transport ──────────────────────────────────────────────────────────── + + @JavascriptInterface + fun play() { runOnMain { controllerProvider()?.play() } } + + @JavascriptInterface + fun pause() { runOnMain { controllerProvider()?.pause() } } + + @JavascriptInterface + fun togglePlay() { + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + if (ctrl.isPlaying) ctrl.pause() else ctrl.play() + } + } + + @JavascriptInterface + fun seekTo(positionMs: Long) { + runOnMain { controllerProvider()?.seekTo(positionMs) } + } + + @JavascriptInterface + fun seekToIndex(index: Int, positionMs: Long) { + runOnMain { controllerProvider()?.seekTo(index, positionMs) } + } + + @JavascriptInterface + fun next() { runOnMain { controllerProvider()?.seekToNextMediaItem() } } + + @JavascriptInterface + fun prev() { + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + if (ctrl.currentPosition > 3000) ctrl.seekTo(0) + else ctrl.seekToPreviousMediaItem() + } + } + + // ─── Playback settings ──────────────────────────────────────────────────── + + @JavascriptInterface + fun setRepeatMode(mode: String) { + runOnMain { + val ctrl = controllerProvider() ?: return@runOnMain + ctrl.repeatMode = when (mode) { + "one" -> Player.REPEAT_MODE_ONE + "all" -> Player.REPEAT_MODE_ALL + else -> Player.REPEAT_MODE_OFF + } + } + } + + @JavascriptInterface + fun setShuffleMode(enabled: Boolean) { + runOnMain { controllerProvider()?.shuffleModeEnabled = enabled } + } + + @JavascriptInterface + fun setVolume(volume: Float) { + runOnMain { controllerProvider()?.volume = volume } + } + + @JavascriptInterface + fun setPlaybackSpeed(speed: Float) { + runOnMain { controllerProvider()?.setPlaybackSpeed(speed) } + } + + // ─── State snapshot (synchronous read from JS) ──────────────────────────── + + @JavascriptInterface + fun getState(): String = stateSnapshot + + // ─── Internal ───────────────────────────────────────────────────────────── + + private fun runOnMain(block: () -> Unit) { + Handler(Looper.getMainLooper()).post(block) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..36298e4 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,21 @@ +[versions] +agp = "8.13.2" +kotlin = "1.9.23" +coreKtx = "1.13.1" +appcompat = "1.7.0" +material = "1.12.0" +media3 = "1.4.1" +coroutines = "1.8.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +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" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/src/main.js b/src/main.js index 0c59b77..8bb63d2 100644 --- a/src/main.js +++ b/src/main.js @@ -166,10 +166,12 @@ window.addEventListener('load', () => { const s = S.queue[S.qIdx]; if(!s) return; updateMediaSession(s); - // Don't stomp playbackState during a src-swap — _trackChanging guards the transition - if(navigator.mediaSession && !S._trackChanging) + // Don't stomp playbackState during a src-swap — _trackChanging guards the transition. + // Skip when native is active: Media3 owns the system media session. + if(navigator.mediaSession && !S._trackChanging && !window.NSBridge) navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing'; - // If audio was playing before backgrounding but is now paused (OS kill), try to resume + // If audio was playing before backgrounding but is now paused (OS kill), try to resume. + // No-op when native (S.audio.src is empty; native handles its own recovery). if(!S.audio.paused) return; if(s && S.audio.src) S.audio.play().catch(() => { S._trackChanging = false; }); } diff --git a/src/player.js b/src/player.js index 192043e..5680167 100644 --- a/src/player.js +++ b/src/player.js @@ -5,10 +5,147 @@ import { api, streamUrl, coverUrl, downloadUrl } from './api.js'; import { loadRecommendations } from './views.js'; import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js'; -// ─── COVER ART URL CACHE ─── +// ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── +// Returns true when running inside the NovaSonic Android wrapper (window.NSBridge present). +// Checked at call-time so it works even if the bridge appears after module load. +const _native = () => !!(window.NSBridge); + +// Track native playback state; driven by window.NSPlayer callbacks below. +let _nativePos = 0; // ms +let _nativeDur = 0; // ms +let _nativePlaying = false; + +// Unified duration / position / seek helpers — use native state when available, +// fall back to HTMLAudioElement when not. +const _dur = () => _native() ? _nativeDur / 1000 : (S.audio.duration || 0); +const _pos = () => _native() ? _nativePos / 1000 : (S.audio.currentTime || 0); +const _seek = ms => _native() ? _nsBridge('seekTo', ms) : (S.audio.currentTime = ms / 1000); + +// Safe NSBridge caller — never throws into JS. +function _nsBridge(method, ...args) { + try { window.NSBridge[method](...args); } catch(_) {} +} + +// Convert a Navidrome song object to the NSBridge Track JSON schema. +function _trackToNative(s) { + return { + id: s.id, + url: streamUrl(s.id), + title: s.title || '', + artist: s.artist || '', + album: s.album || null, + artworkUrl: s.coverArt ? _cachedCoverUrl(s.coverArt, 512) : null, + durationMs: Math.round((s.duration || 0) * 1000), + }; +} + +// ─── NATIVE → JS CALLBACKS ──────────────────────────────────────────────── +// window.NSPlayer is the surface that MusicService calls back into. +// Installed here so it exists before onPageFinished fires. +window.NSPlayer = { + + // Called when MediaController connects (first load or WebView reload). + onReady() { + const raw = window.NSBridge?.getState(); + const state = raw ? (JSON.parse(raw) || {}) : {}; + const nativeLen = state.queueLength || 0; + + if (nativeLen > 0 && nativeLen === S.queue.length) { + // Native still has the same queue (e.g. after a WebView reload) — re-sync UI. + S.qIdx = state.currentIndex ?? S.qIdx; + _nativePlaying = state.isPlaying || false; + _nativePos = state.positionMs || 0; + const s = S.queue[S.qIdx]; + if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); } + setPlayIcon(_nativePlaying); + renderQueue(); + } else if (S.queue.length > 0) { + // Native queue is empty or stale — push JS-side state (restored from localStorage). + const tracks = S.queue.map(_trackToNative); + _nsBridge('setQueue', JSON.stringify(tracks), S.qIdx, false); + } + // Always sync settings. + _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat); + _nsBridge('setShuffleMode', S.shuffle); + }, + + // Play/pause/buffer state changed. + onPlaybackStateChanged(stateJson) { + const state = JSON.parse(stateJson); + const prev = _nativePlaying; + _nativePlaying = state.isPlaying; + if (state.isPlaying !== prev) { + setPlayIcon(state.isPlaying); + document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying); + if (navigator.mediaSession) + navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused'; + } + }, + + // Native moved to a different track (auto-advance, seek, or queue replace). + onTrackChanged(payloadJson) { + const payload = JSON.parse(payloadJson); + S.qIdx = payload.index; + const s = S.queue[S.qIdx]; + if (!s) return; + updatePlayerUI(s, true); + onNpSongChange(s.id); + renderQueue(); + // Radio mode: top up the queue when running low; push new songs to native. + if (S.radioMode && S.queue.length - S.qIdx < 5) { + const prevLen = S.queue.length; + fillRadioQueue().then(() => { + S.queue.slice(prevLen).forEach(t => + _nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1) + ); + }); + } + }, + + // ~250 ms position tick while playing. + onPositionUpdate(posMs, durMs) { + _nativePos = posMs; + _nativeDur = durMs; + if (S._scrubbing) return; + const dur = durMs / 1000; + const cur = posMs / 1000; + if (dur <= 0) return; + const p = (cur / dur) * 100; + document.getElementById('progress-fill').style.width = p + '%'; + document.getElementById('time-cur').textContent = formatDur(cur); + document.getElementById('time-total').textContent = formatDur(dur); + if (S.npOpen) { + const fill = document.getElementById('np-fill'); + const curEl = document.getElementById('np-cur'); + const totEl = document.getElementById('np-tot'); + if (fill) fill.style.width = p + '%'; + if (curEl) curEl.textContent = formatDur(cur); + if (totEl) totEl.textContent = formatDur(dur); + if (S.npLyricsMode) updateLyricsHighlight(posMs); + } + }, + + // Last track finished with repeat off. + onQueueEnded() { + _nativePlaying = false; + setPlayIcon(false); + if (S.radioMode) { + const prevLen = S.queue.length; + fillRadioQueue().then(() => { + const newTracks = S.queue.slice(prevLen); + newTracks.forEach(t => _nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1)); + if (newTracks.length > 0) _nsBridge('play'); + }); + } + }, + + onError(message) { toast('Playback error: ' + message); }, +}; + +// ─── COVER ART URL CACHE ─────────────────────────────────────────────────── // Stable URLs let the OS cache artwork images — without this every skip regenerates a new // auth token URL, Samsung has to re-fetch the image, and the lock screen notification -// visually reloads. Defined here (top of file) so both preload and MediaSession can use it. +// visually reloads. const _coverCache = new Map(); function _cachedCoverUrl(id, size) { const key = `${id}:${size}`; @@ -16,17 +153,15 @@ function _cachedCoverUrl(id, size) { return _coverCache.get(key); } -// ─── AUDIO SESSION — set once, never again ─── -// Reassigning audioSession.type on every track change resets the OS audio session, -// which can cut background network briefly — exactly what causes Doze to kill autoplay. +// ─── AUDIO SESSION — set once, never again ─────────────────────────────── let _audioSessionSet = false; function _setAudioSession() { - if (_audioSessionSet) return; + if (_native() || _audioSessionSet) return; _audioSessionSet = true; try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} } -// ─── PRELOAD (resource hint keeps connection open through Doze gap) ─── +// ─── PRELOAD (resource hint keeps connection open through Doze gap) ─────── let _preUrl = ''; let _preId = ''; let _preLink = null; @@ -40,20 +175,19 @@ function _nextIdx() { } function _preloadNext() { + if (_native()) return; // ExoPlayer handles buffering natively const ni = _nextIdx(); if(ni < 0) return; const next = S.queue[ni]; if(!next || _preId === next.id) return; _preId = next.id; - _preUrl = streamUrl(next.id); // generate URL once — reused by main element + _preUrl = streamUrl(next.id); if(_preLink) _preLink.remove(); _preLink = document.createElement('link'); _preLink.rel = 'prefetch'; _preLink.as = 'fetch'; _preLink.href = _preUrl; document.head.appendChild(_preLink); - // Pre-warm cover art so lock screen notification never cold-starts artwork on first skip. - // Uses _cachedCoverUrl so the same token is reused when updateMediaSession() runs later. if(next.coverArt) { [96, 300, 512].forEach(size => { const img = new Image(); @@ -67,13 +201,22 @@ function _clearPreload() { if(_preLink) { _preLink.remove(); _preLink = null; } } -// ─── PLAYBACK ─── -let _playGen = 0; // incremented on every playCurrent() — stale callbacks check this +// ─── PLAYBACK ───────────────────────────────────────────────────────────── +let _playGen = 0; export function playQueue(songs, startIdx) { S.queue = songs.map(s => ({...s})); - S.qIdx = startIdx; + S.qIdx = startIdx; _clearPreload(); + + if (_native()) { + const tracks = S.queue.map(_trackToNative); + _nsBridge('setQueue', JSON.stringify(tracks), startIdx, true); + renderQueue(); + if (S.radioMode) fillRadioQueue(); + return; + } + playCurrent(); renderQueue(); if(S.radioMode) fillRadioQueue(); @@ -82,13 +225,21 @@ export function playQueue(songs, startIdx) { export function playCurrent() { if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; const s = S.queue[S.qIdx]; + + if (_native()) { + // Update DOM immediately for snappy feedback; onTrackChanged will confirm. + S._trackChanging = true; + updatePlayerUI(s, true); + onNpSongChange(s.id); + renderQueue(); + _nsBridge('seekToIndex', S.qIdx, 0); + _nsBridge('play'); + return; + } + const gen = ++_playGen; S._trackChanging = true; - // Hold notification open with 'playing' state BEFORE touching metadata or src if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; - // DOM update only — MediaSession metadata deferred until audio actually starts. - // Changing metadata during the HAVE_NOTHING window (after src swap) causes Chrome to - // signal "stopped" to Android, making Samsung close and re-open the lock screen card. updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); @@ -99,32 +250,23 @@ export function playCurrent() { const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; - // Called once per transition when audio is confirmed playing const _applySession = () => { if (_playGen !== gen) return; updateMediaSession(s); setTimeout(_preloadNext, 2000); }; - // ── canplay-first recovery (armed BEFORE play() call) ────────────────────── - // canplay fires from the audio engine — not throttled by Android Doze or JS - // timer limits — so this is the only reliable wakeup on a locked screen. - // We arm it now so it's ready whether play() hangs, fails, or never resolves. let _guardTimer = null; let _canplayFn = null; function _armCanplay() { if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); clearTimeout(_guardTimer); - _guardTimer = setTimeout(() => { - // canplay didn't fire in 10 s — Doze likely stalled the network. - // Try play() anyway; _onForeground() will retry if the user opens the app. if (_playGen !== gen) return; S._trackChanging = false; S.audio.play().then(_applySession).catch(() => {}); }, 10000); - _canplayFn = () => { clearTimeout(_guardTimer); _canplayFn = null; @@ -136,23 +278,19 @@ export function playCurrent() { S.audio.addEventListener('canplay', _canplayFn, { once: true }); } - _armCanplay(); // arm before play() so we never miss the canplay event + _armCanplay(); _setAudioSession(); S.audio.play() .then(() => { - // Immediate success — cancel canplay guard, apply session now clearTimeout(_guardTimer); if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); _applySession(); }) .catch(err => { if (_playGen !== gen) return; - // Any failure (AbortError, NotAllowedError, etc.): reset the element so - // the audio engine starts fresh and canplay fires reliably. - // _trackChanging stays true so load()'s pause event stays suppressed. S.audio.src = src; S.audio.load(); - _armCanplay(); // re-arm: canplay may have already fired once before the error + _armCanplay(); }); } @@ -200,7 +338,6 @@ function _crossfadeNpBg(cv) { const nextEl = document.getElementById(`np-bg-${nextId}`); if (!currEl || !nextEl) return; nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none'; - // Double rAF: wait for browser to paint the new bg (GPU decode) before fading requestAnimationFrame(() => requestAnimationFrame(() => { nextEl.style.opacity = '1'; currEl.style.opacity = '0'; @@ -240,8 +377,12 @@ export function _syncNpDirect(s) { syncNpStar(); } -// ─── TRANSPORT ─── -export function togglePlay() { if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); } +// ─── TRANSPORT ──────────────────────────────────────────────────────────── + +export function togglePlay() { + if (_native()) { _nsBridge('togglePlay'); return; } + if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); +} export function setPlayIcon(playing) { document.getElementById('play-icon').innerHTML = playing @@ -254,18 +395,18 @@ export function setPlayIcon(playing) { const cover = document.getElementById('np-cover'); if(cover) cover.classList.toggle('playing', playing); } - // Android native MediaSession bridge - if (window.Android) { try { window.Android.setPlaybackState(playing); } catch(_) {} } } export function prevSong() { _clearPreload(); + if (_native()) { _nsBridge('prev'); return; } if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; } if(S.qIdx > 0) { S.qIdx--; playCurrent(); } } export function nextSong() { _clearPreload(); + if (_native()) { _nsBridge('next'); return; } if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length); else if(S.qIdx < S.queue.length - 1) S.qIdx++; else if(S.repeat) S.qIdx = 0; @@ -296,15 +437,18 @@ export function toggleShuffle() { S.shuffle = !S.shuffle; localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0'); _applyShuffleRepeat(); + if (_native()) _nsBridge('setShuffleMode', S.shuffle); } export function toggleRepeat() { S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false; localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat); _applyShuffleRepeat(); + if (_native()) _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat); } -// ─── RADIO ─── +// ─── RADIO ──────────────────────────────────────────────────────────────── + export function _applyRadioBtn() { const btn = document.getElementById('btn-radio-mode'); if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; } @@ -328,23 +472,17 @@ export async function fillRadioQueue() { } catch {} } -// ─── PROGRESS & SCRUBBING ─── -let _androidPosTick = 0; +// ─── PROGRESS & SCRUBBING ───────────────────────────────────────────────── + export function updateProgress() { + if(_native()) return; // driven by NSPlayer.onPositionUpdate instead if(!S.audio.duration || S._scrubbing) return; syncMediaSessionPosition(); - // Preload next track at 30s remaining so network stays alive through Doze gap const rem = S.audio.duration - S.audio.currentTime; if(rem > 0 && rem < 30 && !S._trackChanging) _preloadNext(); - // Update Android native position every ~5 seconds to keep lock screen scrubber in sync - const now = Date.now(); - if(now - _androidPosTick > 5000) { - _androidPosTick = now; - if (window.Android) { try { window.Android.updatePosition(S.audio.currentTime, S.audio.duration); } catch(_) {} } - } const p = (S.audio.currentTime / S.audio.duration) * 100; document.getElementById('progress-fill').style.width = p + '%'; - document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime); + document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime); document.getElementById('time-total').textContent = formatDur(S.audio.duration); if(S.npOpen) { const fill = document.getElementById('np-fill'); @@ -359,28 +497,35 @@ export function updateProgress() { export function seekTo(e) { const bar = document.getElementById('progress-bar'); - if (!bar || !S.audio.duration) return; + const dur = _dur(); + if (!bar || !dur) return; const rect = bar.getBoundingClientRect(); - const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const fill = document.getElementById('progress-fill'); if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; } - S.audio.currentTime = pos * S.audio.duration; S._scrubbing = true; - const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; - S.audio.addEventListener('seeked', done, { once: true }); - setTimeout(done, 1500); + if (_native()) { + _nsBridge('seekTo', Math.round(pos * _nativeDur)); + const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; + setTimeout(done, 500); + } else { + S.audio.currentTime = pos * dur; + const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; + S.audio.addEventListener('seeked', done, { once: true }); + setTimeout(done, 1500); + } } export function initProgressScrub() { _initBarScrub( document.getElementById('progress-bar'), document.getElementById('progress-fill'), - pos => { document.getElementById('time-cur').textContent = formatDur(pos * S.audio.duration); } + pos => { document.getElementById('time-cur').textContent = formatDur(pos * _dur()); } ); _initBarScrub( document.getElementById('np-bar'), document.getElementById('np-fill'), - pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * S.audio.duration); } + pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); } ); } @@ -389,13 +534,18 @@ function _initBarScrub(bar, fill, onMove) { function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); } function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); } function moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); } - function endScrub(pos) { - fill.style.width = (pos * 100) + '%'; // pin visual position before re-enabling transition - if(S.audio.duration) { - S.audio.currentTime = pos * S.audio.duration; + function endScrub(pos) { + fill.style.width = (pos * 100) + '%'; + const dur = _dur(); + if (_native()) { + _nsBridge('seekTo', Math.round(pos * _nativeDur)); + S._scrubbing = false; + requestAnimationFrame(() => { fill.style.transition = ''; }); + } else if (dur) { + S.audio.currentTime = pos * dur; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); }; S.audio.addEventListener('seeked', done, { once: true }); - setTimeout(done, 1500); // fallback if seeked never fires + setTimeout(done, 1500); } else { S._scrubbing = false; fill.style.transition = ''; @@ -419,24 +569,15 @@ export function setVolume(v) { S.previewAudio.volume = vol; const absAudio = document.getElementById('abs-audio'); if (absAudio) absAudio.volume = vol; - // sync all volume sliders document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; }); + if (_native()) _nsBridge('setVolume', vol); } -// ─── MEDIA SESSION ─── -export function updateMediaSession(s) { - // Android native MediaSession bridge — use 512px art for high-DPI screens - if (window.Android) { - try { - window.Android.updateTrack( - s.title || '', - s.artist || '', - s.coverArt ? coverUrl(s.coverArt, 512) : '', - s.duration || 0 - ); - } catch(_) {} - } +// ─── MEDIA SESSION ──────────────────────────────────────────────────────── +export function updateMediaSession(s) { + // When native is active, Media3 owns the system notification — don't touch it. + if (_native()) return; if(!navigator.mediaSession) return; const artwork = s.coverArt ? [ { src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' }, @@ -444,16 +585,17 @@ export function updateMediaSession(s) { { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, ] : []; navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); - navigator.mediaSession.setActionHandler('play', togglePlay); - navigator.mediaSession.setActionHandler('pause', togglePlay); + navigator.mediaSession.setActionHandler('play', togglePlay); + navigator.mediaSession.setActionHandler('pause', togglePlay); navigator.mediaSession.setActionHandler('previoustrack', prevSong); - navigator.mediaSession.setActionHandler('nexttrack', nextSong); + navigator.mediaSession.setActionHandler('nexttrack', nextSong); navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); - navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); + navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); } export function syncMediaSessionPosition() { + if (_native()) return; if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return; const now = Date.now(); if(now - S._msPosLast < 1000) return; @@ -461,7 +603,8 @@ export function syncMediaSessionPosition() { try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {} } -// ─── STAR ─── +// ─── STAR ───────────────────────────────────────────────────────────────── + export async function toggleStar(song, btn) { try { await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`); @@ -506,7 +649,8 @@ export function npToggleStar() { .catch(e => toast('Fehler: ' + e.message)); } -// ─── QUEUE PANEL ─── +// ─── QUEUE PANEL ────────────────────────────────────────────────────────── + let _renderedQueueKey = ''; export function renderQueue() { @@ -515,7 +659,6 @@ export function renderQueue() { const queueKey = S.queue.map(s => s.id).join(','); if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) { - // Queue unchanged — only update active class in-place (no image reload) el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx)); el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); return; @@ -541,7 +684,8 @@ export function toggleQueue() { if(S.queueOpen) renderQueue(); } -// ─── DOWNLOAD ─── +// ─── DOWNLOAD ───────────────────────────────────────────────────────────── + export function dlSongLocal(s) { const a = document.createElement('a'); a.href = downloadUrl(s.id); @@ -554,11 +698,15 @@ export function downloadCurrent() { if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]); } -// ─── PREVIEW ─── +// ─── PREVIEW ────────────────────────────────────────────────────────────── + export function playPreview(track) { if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; } - const wasPlaying = !S.audio.paused; - if(wasPlaying) S.audio.pause(); + const wasPlaying = _native() ? _nativePlaying : !S.audio.paused; + if(wasPlaying) { + if (_native()) _nsBridge('pause'); + else S.audio.pause(); + } S.previewCurrent = track; S.previewCurrent._resumeMain = wasPlaying; S.previewAudio.volume = S.audio.volume; @@ -578,7 +726,10 @@ export function stopPreview(resume) { document.getElementById('preview-bar').classList.remove('visible'); document.getElementById('preview-fill').style.width = '0%'; S.previewCurrent = null; - if(shouldResume && S.audio.src) S.audio.play(); + if (shouldResume) { + if (_native()) _nsBridge('play'); + else if (S.audio.src) S.audio.play(); + } } export function updatePreviewProgress() { @@ -586,7 +737,8 @@ export function updatePreviewProgress() { document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%'; } -// ─── RESTORE LAST PLAYED ─── +// ─── RESTORE LAST PLAYED ────────────────────────────────────────────────── + export function _restoreLastPlayed() { try { const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); @@ -610,11 +762,9 @@ export function _restoreLastPlayed() { art.innerHTML = cv ? `` : '
♪
'; updateStarBtn(); document.title = `${s.title} — NovaSonic`; - // set src so the play button works immediately without needing to re-select the song + if (_native()) return; // native will sync state via NSPlayer.onReady + // Web path: prime the audio element so play button works immediately. S.audio.src = streamUrl(s.id); S.audio.load(); - // Do NOT call updateMediaSession() here — setting stale metadata before the user - // starts playing means playCurrent()'s _applySession() later looks like a "new session" - // to Samsung (old song → new song metadata while playing → notification resets). } catch {} }