feat: native Android audio via Media3 ExoPlayer + full NSBridge JS adapter

- Replace legacy MediaBrowserServiceCompat/MediaSessionCompat with
  Media3 MediaSessionService (MusicService.kt) + ExoPlayer set to
  WAKE_MODE_NETWORK — holds WakeLock + WifiLock through Doze so audio
  plays uninterrupted on locked screen
- Media3 manages the notification; no manual NotificationCompat.Builder
  → no disappear/reappear flicker when skipping tracks
- Full bidirectional bridge:
    JS → native: window.NSBridge.* (WebBridge.kt, 13 methods)
    Native → JS: window.NSPlayer.* callbacks (onReady, onTrackChanged,
      onPositionUpdate, onPlaybackStateChanged, onQueueEnded, onError)
- player.js: progressive enhancement — all playback routes through
  NSBridge when running inside the wrapper, falls back to S.audio on
  plain PWA/browser; removes all window.Android.* calls
- main.js: guard _onForeground mediaSession playbackState setter behind
  !window.NSBridge so native Media3 session is never stomped
- ProGuard rules updated to keep WebBridge @JavascriptInterface methods
  and Media3 classes
- bridge-test.html: manual integration test page for every bridge method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-05 01:14:28 +02:00
1 parent 1ede8727a2
commit 0e17d2e5db
15 files changed
+1082 -84

No files matched your search

+48
View File
@@ -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)
}
+8
View File
@@ -0,0 +1,8 @@
# Keep @JavascriptInterface methods — called by WebView via reflection, stripped otherwise.
-keepclassmembers class com.novasonic.app.WebBridge {
@android.webkit.JavascriptInterface <methods>;
}
# Keep Media3 session / exoplayer classes used by reflection / service binding.
-keep class androidx.media3.** { *; }
-keep class com.google.android.exoplayer2.** { *; }
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.NovaSonic"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".MusicService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!-- Android Auto support -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
</application>
</manifest>
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NSBridge Test</title>
<style>
body { font-family: monospace; background: #111; color: #eee; padding: 16px; }
h2 { color: #8af; margin: 24px 0 8px; }
button { background: #222; color: #8af; border: 1px solid #444; border-radius: 6px;
padding: 8px 14px; margin: 4px; cursor: pointer; font-family: monospace; }
button:hover { background: #333; }
#log { background: #000; border: 1px solid #333; border-radius: 6px; padding: 12px;
height: 220px; overflow-y: auto; font-size: 12px; white-space: pre-wrap; }
input { background: #222; color: #eee; border: 1px solid #444; border-radius: 4px;
padding: 6px 10px; font-family: monospace; width: 280px; }
.ok { color: #8f8; }
.err { color: #f88; }
.evt { color: #fa8; }
</style>
</head>
<body>
<h1>NovaSonic Bridge Test</h1>
<p>Load this page as a local asset to verify the NSBridge surface before connecting the real PWA.</p>
<div id="log">Waiting for bridge...\n</div>
<h2>Queue</h2>
<button onclick="testSetQueue()">setQueue (2 tracks, autoPlay=true)</button>
<button onclick="testAddToQueue()">addToQueue (append)</button>
<button onclick="cmd('removeFromQueue', 1)">removeFromQueue(1)</button>
<button onclick="cmd('clearQueue')">clearQueue</button>
<h2>Transport</h2>
<button onclick="cmd('play')">play</button>
<button onclick="cmd('pause')">pause</button>
<button onclick="cmd('togglePlay')">togglePlay</button>
<button onclick="cmd('next')">next</button>
<button onclick="cmd('prev')">prev</button>
<button onclick="testSeek()">seekTo(30000)</button>
<button onclick="cmd('seekToIndex', 0, 0)">seekToIndex(0,0)</button>
<h2>Settings</h2>
<button onclick="cmd('setRepeatMode','all')">repeat all</button>
<button onclick="cmd('setRepeatMode','one')">repeat one</button>
<button onclick="cmd('setRepeatMode','off')">repeat off</button>
<button onclick="cmd('setShuffleMode',true)">shuffle on</button>
<button onclick="cmd('setShuffleMode',false)">shuffle off</button>
<button onclick="cmd('setVolume',0.5)">volume 50%</button>
<button onclick="cmd('setPlaybackSpeed',1.5)">speed 1.5x</button>
<button onclick="cmd('setPlaybackSpeed',1.0)">speed 1.0x</button>
<h2>State</h2>
<button onclick="showState()">getState()</button>
<h2>NSPlayer callbacks (incoming from native)</h2>
<p style="color:#aaa;font-size:12px">These fire automatically when native state changes.</p>
<script>
const log = document.getElementById('log');
function print(msg, cls) {
const line = document.createElement('span');
line.className = cls || '';
line.textContent = new Date().toISOString().slice(11,23) + ' ' + msg + '\n';
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
// ── Bridge availability check ─────────────────────────────────────────────
function hasBridge() {
if (!window.NSBridge) { print('❌ window.NSBridge not found — not running inside NovaSonic app', 'err'); return false; }
return true;
}
// ── Generic command helper ────────────────────────────────────────────────
function cmd(method, ...args) {
if (!hasBridge()) return;
try {
const result = window.NSBridge[method](...args);
print(`→ NSBridge.${method}(${args.map(a=>JSON.stringify(a)).join(', ')})${result !== undefined ? ' = ' + result : ''}`, 'ok');
} catch(e) {
print(`❌ NSBridge.${method}: ${e}`, 'err');
}
}
// ── Queue helpers ─────────────────────────────────────────────────────────
const DEMO_TRACKS = [
{
id: "track-1",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
title: "SoundHelix Song 1",
artist: "T. Schürger",
album: "Demo Album",
artworkUrl: null,
durationMs: 372000
},
{
id: "track-2",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
title: "SoundHelix Song 2",
artist: "T. Schürger",
album: "Demo Album",
artworkUrl: null,
durationMs: 390000
}
];
function testSetQueue() {
if (!hasBridge()) return;
try {
window.NSBridge.setQueue(JSON.stringify(DEMO_TRACKS), 0, true);
print('→ NSBridge.setQueue([2 tracks], startIndex=0, autoPlay=true)', 'ok');
} catch(e) { print('❌ setQueue: ' + e, 'err'); }
}
function testAddToQueue() {
if (!hasBridge()) return;
const track = { id: "track-3", url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
title: "SoundHelix Song 3", artist: "T. Schürger", album: null, artworkUrl: null, durationMs: 0 };
try {
window.NSBridge.addToQueue(JSON.stringify(track), -1);
print('→ NSBridge.addToQueue(track-3, position=-1 [append])', 'ok');
} catch(e) { print('❌ addToQueue: ' + e, 'err'); }
}
function testSeek() { cmd('seekTo', 30000); }
function showState() {
if (!hasBridge()) return;
try {
const raw = window.NSBridge.getState();
print('← getState() = ' + raw, 'ok');
} catch(e) { print('❌ getState: ' + e, 'err'); }
}
// ── NSPlayer callbacks (Native → JS) ─────────────────────────────────────
window.NSPlayer = {
onReady() {
print('← NSPlayer.onReady()', 'evt');
},
onPlaybackStateChanged(stateJson) {
try {
const s = JSON.parse(stateJson);
print(`← NSPlayer.onPlaybackStateChanged isPlaying=${s.isPlaying} isBuffering=${s.isBuffering}`, 'evt');
} catch(e) { print('← NSPlayer.onPlaybackStateChanged (parse error): ' + stateJson, 'err'); }
},
onTrackChanged(payloadJson) {
try {
const p = JSON.parse(payloadJson);
print(`← NSPlayer.onTrackChanged index=${p.index} trackId=${p.trackId} reason=${p.reason}`, 'evt');
} catch(e) { print('← NSPlayer.onTrackChanged (parse error): ' + payloadJson, 'err'); }
},
onPositionUpdate(positionMs, durationMs) {
// High-frequency — only log every 5 s to keep the log readable
if (!window._lastPosPrint || Date.now() - window._lastPosPrint > 5000) {
window._lastPosPrint = Date.now();
print(`← NSPlayer.onPositionUpdate pos=${positionMs}ms dur=${durationMs}ms`, 'evt');
}
},
onQueueEnded() {
print('← NSPlayer.onQueueEnded()', 'evt');
},
onError(message) {
print('← NSPlayer.onError: ' + message, 'err');
},
onMediaButtonAction(action) {
print('← NSPlayer.onMediaButtonAction: ' + action, 'evt');
}
};
// ── Init ──────────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
if (window.NSBridge) {
print('✓ window.NSBridge detected', 'ok');
} else {
print('ℹ window.NSBridge not found — run inside NovaSonic app for full test', 'err');
print(' (Callback handlers are registered; native can still fire them)', '');
}
});
</script>
</body>
</html>
@@ -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<Track> = try {
val arr = JSONArray(json)
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
emptyList()
}
@@ -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<MediaController>? = 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.<method>(<args>) 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
)
}
}
}
@@ -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.
@@ -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")
}
}
}
@@ -0,0 +1,4 @@
package com.novasonic.app
// Replaced by Media3-managed notifications (MusicService.kt).
// Notification actions are now handled natively by Media3 MediaSession.
@@ -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
}
@@ -0,0 +1,4 @@
package com.novasonic.app
// Replaced by WebBridge.kt (window.NSBridge).
// Kept as an empty file so git history is preserved.
@@ -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)
}
}
+21
View File
@@ -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" }
+5 -3
View File
@@ -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; });
}
+231 -81
View File
@@ -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;
_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,20 +472,14 @@ 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);
@@ -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 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;
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()); }
);
}
@@ -390,12 +535,17 @@ function _initBarScrub(bar, fill, onMove) {
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;
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 ───
// ─── 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(_) {}
}
// 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' },
@@ -454,6 +595,7 @@ export function updateMediaSession(s) {
}
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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
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 {}
}