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
+1093
-95
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];
|
const s = S.queue[S.qIdx];
|
||||||
if(!s) return;
|
if(!s) return;
|
||||||
updateMediaSession(s);
|
updateMediaSession(s);
|
||||||
// Don't stomp playbackState during a src-swap — _trackChanging guards the transition
|
// Don't stomp playbackState during a src-swap — _trackChanging guards the transition.
|
||||||
if(navigator.mediaSession && !S._trackChanging)
|
// 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';
|
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.audio.paused) return;
|
||||||
if(s && S.audio.src) S.audio.play().catch(() => { S._trackChanging = false; });
|
if(s && S.audio.src) S.audio.play().catch(() => { S._trackChanging = false; });
|
||||||
}
|
}
|
||||||
|
|||||||
+242
-92
@@ -5,10 +5,147 @@ import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
|
|||||||
import { loadRecommendations } from './views.js';
|
import { loadRecommendations } from './views.js';
|
||||||
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.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
|
// 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
|
// 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();
|
const _coverCache = new Map();
|
||||||
function _cachedCoverUrl(id, size) {
|
function _cachedCoverUrl(id, size) {
|
||||||
const key = `${id}:${size}`;
|
const key = `${id}:${size}`;
|
||||||
@@ -16,17 +153,15 @@ function _cachedCoverUrl(id, size) {
|
|||||||
return _coverCache.get(key);
|
return _coverCache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── AUDIO SESSION — set once, never again ───
|
// ─── 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.
|
|
||||||
let _audioSessionSet = false;
|
let _audioSessionSet = false;
|
||||||
function _setAudioSession() {
|
function _setAudioSession() {
|
||||||
if (_audioSessionSet) return;
|
if (_native() || _audioSessionSet) return;
|
||||||
_audioSessionSet = true;
|
_audioSessionSet = true;
|
||||||
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
|
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 _preUrl = '';
|
||||||
let _preId = '';
|
let _preId = '';
|
||||||
let _preLink = null;
|
let _preLink = null;
|
||||||
@@ -40,20 +175,19 @@ function _nextIdx() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _preloadNext() {
|
function _preloadNext() {
|
||||||
|
if (_native()) return; // ExoPlayer handles buffering natively
|
||||||
const ni = _nextIdx();
|
const ni = _nextIdx();
|
||||||
if(ni < 0) return;
|
if(ni < 0) return;
|
||||||
const next = S.queue[ni];
|
const next = S.queue[ni];
|
||||||
if(!next || _preId === next.id) return;
|
if(!next || _preId === next.id) return;
|
||||||
_preId = next.id;
|
_preId = next.id;
|
||||||
_preUrl = streamUrl(next.id); // generate URL once — reused by main element
|
_preUrl = streamUrl(next.id);
|
||||||
if(_preLink) _preLink.remove();
|
if(_preLink) _preLink.remove();
|
||||||
_preLink = document.createElement('link');
|
_preLink = document.createElement('link');
|
||||||
_preLink.rel = 'prefetch';
|
_preLink.rel = 'prefetch';
|
||||||
_preLink.as = 'fetch';
|
_preLink.as = 'fetch';
|
||||||
_preLink.href = _preUrl;
|
_preLink.href = _preUrl;
|
||||||
document.head.appendChild(_preLink);
|
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) {
|
if(next.coverArt) {
|
||||||
[96, 300, 512].forEach(size => {
|
[96, 300, 512].forEach(size => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -67,13 +201,22 @@ function _clearPreload() {
|
|||||||
if(_preLink) { _preLink.remove(); _preLink = null; }
|
if(_preLink) { _preLink.remove(); _preLink = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PLAYBACK ───
|
// ─── PLAYBACK ─────────────────────────────────────────────────────────────
|
||||||
let _playGen = 0; // incremented on every playCurrent() — stale callbacks check this
|
let _playGen = 0;
|
||||||
|
|
||||||
export function playQueue(songs, startIdx) {
|
export function playQueue(songs, startIdx) {
|
||||||
S.queue = songs.map(s => ({...s}));
|
S.queue = songs.map(s => ({...s}));
|
||||||
S.qIdx = startIdx;
|
S.qIdx = startIdx;
|
||||||
_clearPreload();
|
_clearPreload();
|
||||||
|
|
||||||
|
if (_native()) {
|
||||||
|
const tracks = S.queue.map(_trackToNative);
|
||||||
|
_nsBridge('setQueue', JSON.stringify(tracks), startIdx, true);
|
||||||
|
renderQueue();
|
||||||
|
if (S.radioMode) fillRadioQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
playCurrent();
|
playCurrent();
|
||||||
renderQueue();
|
renderQueue();
|
||||||
if(S.radioMode) fillRadioQueue();
|
if(S.radioMode) fillRadioQueue();
|
||||||
@@ -82,13 +225,21 @@ export function playQueue(songs, startIdx) {
|
|||||||
export function playCurrent() {
|
export function playCurrent() {
|
||||||
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
|
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
|
||||||
const s = S.queue[S.qIdx];
|
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;
|
const gen = ++_playGen;
|
||||||
S._trackChanging = true;
|
S._trackChanging = true;
|
||||||
// Hold notification open with 'playing' state BEFORE touching metadata or src
|
|
||||||
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
|
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);
|
updatePlayerUI(s, true);
|
||||||
onNpSongChange(s.id);
|
onNpSongChange(s.id);
|
||||||
renderQueue();
|
renderQueue();
|
||||||
@@ -99,32 +250,23 @@ export function playCurrent() {
|
|||||||
|
|
||||||
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
||||||
|
|
||||||
// Called once per transition when audio is confirmed playing
|
|
||||||
const _applySession = () => {
|
const _applySession = () => {
|
||||||
if (_playGen !== gen) return;
|
if (_playGen !== gen) return;
|
||||||
updateMediaSession(s);
|
updateMediaSession(s);
|
||||||
setTimeout(_preloadNext, 2000);
|
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 _guardTimer = null;
|
||||||
let _canplayFn = null;
|
let _canplayFn = null;
|
||||||
|
|
||||||
function _armCanplay() {
|
function _armCanplay() {
|
||||||
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
||||||
clearTimeout(_guardTimer);
|
clearTimeout(_guardTimer);
|
||||||
|
|
||||||
_guardTimer = setTimeout(() => {
|
_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;
|
if (_playGen !== gen) return;
|
||||||
S._trackChanging = false;
|
S._trackChanging = false;
|
||||||
S.audio.play().then(_applySession).catch(() => {});
|
S.audio.play().then(_applySession).catch(() => {});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
_canplayFn = () => {
|
_canplayFn = () => {
|
||||||
clearTimeout(_guardTimer);
|
clearTimeout(_guardTimer);
|
||||||
_canplayFn = null;
|
_canplayFn = null;
|
||||||
@@ -136,23 +278,19 @@ export function playCurrent() {
|
|||||||
S.audio.addEventListener('canplay', _canplayFn, { once: true });
|
S.audio.addEventListener('canplay', _canplayFn, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
_armCanplay(); // arm before play() so we never miss the canplay event
|
_armCanplay();
|
||||||
_setAudioSession();
|
_setAudioSession();
|
||||||
S.audio.play()
|
S.audio.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Immediate success — cancel canplay guard, apply session now
|
|
||||||
clearTimeout(_guardTimer);
|
clearTimeout(_guardTimer);
|
||||||
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
||||||
_applySession();
|
_applySession();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (_playGen !== gen) return;
|
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.src = src;
|
||||||
S.audio.load();
|
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}`);
|
const nextEl = document.getElementById(`np-bg-${nextId}`);
|
||||||
if (!currEl || !nextEl) return;
|
if (!currEl || !nextEl) return;
|
||||||
nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none';
|
nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none';
|
||||||
// Double rAF: wait for browser to paint the new bg (GPU decode) before fading
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
nextEl.style.opacity = '1';
|
nextEl.style.opacity = '1';
|
||||||
currEl.style.opacity = '0';
|
currEl.style.opacity = '0';
|
||||||
@@ -240,8 +377,12 @@ export function _syncNpDirect(s) {
|
|||||||
syncNpStar();
|
syncNpStar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── TRANSPORT ───
|
// ─── TRANSPORT ────────────────────────────────────────────────────────────
|
||||||
export function togglePlay() { if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); }
|
|
||||||
|
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) {
|
export function setPlayIcon(playing) {
|
||||||
document.getElementById('play-icon').innerHTML = playing
|
document.getElementById('play-icon').innerHTML = playing
|
||||||
@@ -254,18 +395,18 @@ export function setPlayIcon(playing) {
|
|||||||
const cover = document.getElementById('np-cover');
|
const cover = document.getElementById('np-cover');
|
||||||
if(cover) cover.classList.toggle('playing', playing);
|
if(cover) cover.classList.toggle('playing', playing);
|
||||||
}
|
}
|
||||||
// Android native MediaSession bridge
|
|
||||||
if (window.Android) { try { window.Android.setPlaybackState(playing); } catch(_) {} }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prevSong() {
|
export function prevSong() {
|
||||||
_clearPreload();
|
_clearPreload();
|
||||||
|
if (_native()) { _nsBridge('prev'); return; }
|
||||||
if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; }
|
if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; }
|
||||||
if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
|
if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextSong() {
|
export function nextSong() {
|
||||||
_clearPreload();
|
_clearPreload();
|
||||||
|
if (_native()) { _nsBridge('next'); return; }
|
||||||
if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length);
|
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.qIdx < S.queue.length - 1) S.qIdx++;
|
||||||
else if(S.repeat) S.qIdx = 0;
|
else if(S.repeat) S.qIdx = 0;
|
||||||
@@ -296,15 +437,18 @@ export function toggleShuffle() {
|
|||||||
S.shuffle = !S.shuffle;
|
S.shuffle = !S.shuffle;
|
||||||
localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0');
|
localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0');
|
||||||
_applyShuffleRepeat();
|
_applyShuffleRepeat();
|
||||||
|
if (_native()) _nsBridge('setShuffleMode', S.shuffle);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleRepeat() {
|
export function toggleRepeat() {
|
||||||
S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false;
|
S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false;
|
||||||
localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat);
|
localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat);
|
||||||
_applyShuffleRepeat();
|
_applyShuffleRepeat();
|
||||||
|
if (_native()) _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RADIO ───
|
// ─── RADIO ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function _applyRadioBtn() {
|
export function _applyRadioBtn() {
|
||||||
const btn = document.getElementById('btn-radio-mode');
|
const btn = document.getElementById('btn-radio-mode');
|
||||||
if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; }
|
if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; }
|
||||||
@@ -328,23 +472,17 @@ export async function fillRadioQueue() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PROGRESS & SCRUBBING ───
|
// ─── PROGRESS & SCRUBBING ─────────────────────────────────────────────────
|
||||||
let _androidPosTick = 0;
|
|
||||||
export function updateProgress() {
|
export function updateProgress() {
|
||||||
|
if(_native()) return; // driven by NSPlayer.onPositionUpdate instead
|
||||||
if(!S.audio.duration || S._scrubbing) return;
|
if(!S.audio.duration || S._scrubbing) return;
|
||||||
syncMediaSessionPosition();
|
syncMediaSessionPosition();
|
||||||
// Preload next track at 30s remaining so network stays alive through Doze gap
|
|
||||||
const rem = S.audio.duration - S.audio.currentTime;
|
const rem = S.audio.duration - S.audio.currentTime;
|
||||||
if(rem > 0 && rem < 30 && !S._trackChanging) _preloadNext();
|
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;
|
const p = (S.audio.currentTime / S.audio.duration) * 100;
|
||||||
document.getElementById('progress-fill').style.width = p + '%';
|
document.getElementById('progress-fill').style.width = p + '%';
|
||||||
document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime);
|
document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime);
|
||||||
document.getElementById('time-total').textContent = formatDur(S.audio.duration);
|
document.getElementById('time-total').textContent = formatDur(S.audio.duration);
|
||||||
if(S.npOpen) {
|
if(S.npOpen) {
|
||||||
const fill = document.getElementById('np-fill');
|
const fill = document.getElementById('np-fill');
|
||||||
@@ -359,28 +497,35 @@ export function updateProgress() {
|
|||||||
|
|
||||||
export function seekTo(e) {
|
export function seekTo(e) {
|
||||||
const bar = document.getElementById('progress-bar');
|
const bar = document.getElementById('progress-bar');
|
||||||
if (!bar || !S.audio.duration) return;
|
const dur = _dur();
|
||||||
|
if (!bar || !dur) return;
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
const fill = document.getElementById('progress-fill');
|
const fill = document.getElementById('progress-fill');
|
||||||
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
|
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
|
||||||
S.audio.currentTime = pos * S.audio.duration;
|
|
||||||
S._scrubbing = true;
|
S._scrubbing = true;
|
||||||
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
|
if (_native()) {
|
||||||
S.audio.addEventListener('seeked', done, { once: true });
|
_nsBridge('seekTo', Math.round(pos * _nativeDur));
|
||||||
setTimeout(done, 1500);
|
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() {
|
export function initProgressScrub() {
|
||||||
_initBarScrub(
|
_initBarScrub(
|
||||||
document.getElementById('progress-bar'),
|
document.getElementById('progress-bar'),
|
||||||
document.getElementById('progress-fill'),
|
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(
|
_initBarScrub(
|
||||||
document.getElementById('np-bar'),
|
document.getElementById('np-bar'),
|
||||||
document.getElementById('np-fill'),
|
document.getElementById('np-fill'),
|
||||||
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * S.audio.duration); }
|
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,13 +534,18 @@ function _initBarScrub(bar, fill, onMove) {
|
|||||||
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
|
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
|
||||||
function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); }
|
function 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 moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); }
|
||||||
function endScrub(pos) {
|
function endScrub(pos) {
|
||||||
fill.style.width = (pos * 100) + '%'; // pin visual position before re-enabling transition
|
fill.style.width = (pos * 100) + '%';
|
||||||
if(S.audio.duration) {
|
const dur = _dur();
|
||||||
S.audio.currentTime = pos * S.audio.duration;
|
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 = ''; }); };
|
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); };
|
||||||
S.audio.addEventListener('seeked', done, { once: true });
|
S.audio.addEventListener('seeked', done, { once: true });
|
||||||
setTimeout(done, 1500); // fallback if seeked never fires
|
setTimeout(done, 1500);
|
||||||
} else {
|
} else {
|
||||||
S._scrubbing = false;
|
S._scrubbing = false;
|
||||||
fill.style.transition = '';
|
fill.style.transition = '';
|
||||||
@@ -419,24 +569,15 @@ export function setVolume(v) {
|
|||||||
S.previewAudio.volume = vol;
|
S.previewAudio.volume = vol;
|
||||||
const absAudio = document.getElementById('abs-audio');
|
const absAudio = document.getElementById('abs-audio');
|
||||||
if (absAudio) absAudio.volume = vol;
|
if (absAudio) absAudio.volume = vol;
|
||||||
// sync all volume sliders
|
|
||||||
document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; });
|
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(_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function updateMediaSession(s) {
|
||||||
|
// When native is active, Media3 owns the system notification — don't touch it.
|
||||||
|
if (_native()) return;
|
||||||
if(!navigator.mediaSession) return;
|
if(!navigator.mediaSession) return;
|
||||||
const artwork = s.coverArt ? [
|
const artwork = s.coverArt ? [
|
||||||
{ src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' },
|
{ src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' },
|
||||||
@@ -444,16 +585,17 @@ export function updateMediaSession(s) {
|
|||||||
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
||||||
] : [];
|
] : [];
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
||||||
navigator.mediaSession.setActionHandler('play', togglePlay);
|
navigator.mediaSession.setActionHandler('play', togglePlay);
|
||||||
navigator.mediaSession.setActionHandler('pause', togglePlay);
|
navigator.mediaSession.setActionHandler('pause', togglePlay);
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', prevSong);
|
navigator.mediaSession.setActionHandler('previoustrack', prevSong);
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
|
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
|
||||||
navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
|
navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
|
||||||
navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
|
navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
|
||||||
navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
|
navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncMediaSessionPosition() {
|
export function syncMediaSessionPosition() {
|
||||||
|
if (_native()) return;
|
||||||
if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return;
|
if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if(now - S._msPosLast < 1000) return;
|
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 {}
|
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) {
|
export async function toggleStar(song, btn) {
|
||||||
try {
|
try {
|
||||||
await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`);
|
await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`);
|
||||||
@@ -506,7 +649,8 @@ export function npToggleStar() {
|
|||||||
.catch(e => toast('Fehler: ' + e.message));
|
.catch(e => toast('Fehler: ' + e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── QUEUE PANEL ───
|
// ─── QUEUE PANEL ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _renderedQueueKey = '';
|
let _renderedQueueKey = '';
|
||||||
|
|
||||||
export function renderQueue() {
|
export function renderQueue() {
|
||||||
@@ -515,7 +659,6 @@ export function renderQueue() {
|
|||||||
const queueKey = S.queue.map(s => s.id).join(',');
|
const queueKey = S.queue.map(s => s.id).join(',');
|
||||||
|
|
||||||
if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) {
|
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.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx));
|
||||||
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
||||||
return;
|
return;
|
||||||
@@ -541,7 +684,8 @@ export function toggleQueue() {
|
|||||||
if(S.queueOpen) renderQueue();
|
if(S.queueOpen) renderQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DOWNLOAD ───
|
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function dlSongLocal(s) {
|
export function dlSongLocal(s) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = downloadUrl(s.id);
|
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]);
|
if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PREVIEW ───
|
// ─── PREVIEW ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function playPreview(track) {
|
export function playPreview(track) {
|
||||||
if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; }
|
if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; }
|
||||||
const wasPlaying = !S.audio.paused;
|
const wasPlaying = _native() ? _nativePlaying : !S.audio.paused;
|
||||||
if(wasPlaying) S.audio.pause();
|
if(wasPlaying) {
|
||||||
|
if (_native()) _nsBridge('pause');
|
||||||
|
else S.audio.pause();
|
||||||
|
}
|
||||||
S.previewCurrent = track;
|
S.previewCurrent = track;
|
||||||
S.previewCurrent._resumeMain = wasPlaying;
|
S.previewCurrent._resumeMain = wasPlaying;
|
||||||
S.previewAudio.volume = S.audio.volume;
|
S.previewAudio.volume = S.audio.volume;
|
||||||
@@ -578,7 +726,10 @@ export function stopPreview(resume) {
|
|||||||
document.getElementById('preview-bar').classList.remove('visible');
|
document.getElementById('preview-bar').classList.remove('visible');
|
||||||
document.getElementById('preview-fill').style.width = '0%';
|
document.getElementById('preview-fill').style.width = '0%';
|
||||||
S.previewCurrent = null;
|
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() {
|
export function updatePreviewProgress() {
|
||||||
@@ -586,7 +737,8 @@ export function updatePreviewProgress() {
|
|||||||
document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%';
|
document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RESTORE LAST PLAYED ───
|
// ─── RESTORE LAST PLAYED ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function _restoreLastPlayed() {
|
export function _restoreLastPlayed() {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
|
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>';
|
art.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||||
updateStarBtn();
|
updateStarBtn();
|
||||||
document.title = `${s.title} — NovaSonic`;
|
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.src = streamUrl(s.id);
|
||||||
S.audio.load();
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user