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:
1 parent
1ede8727a2
commit
0e17d2e5db
15 files changed
+1082
-84
No files matched your search
@@ -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)
|
||||
}
|
||||
Vendored
+8
@@ -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.** { *; }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {}
|
||||
}
|
||||
Reference in new issue
Block a user