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

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

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

No files matched your search

@@ -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>