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

+242 -92
View File
@@ -5,10 +5,147 @@ import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
import { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
// ─── COVER ART URL CACHE ───
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// Returns true when running inside the NovaSonic Android wrapper (window.NSBridge present).
// Checked at call-time so it works even if the bridge appears after module load.
const _native = () => !!(window.NSBridge);
// Track native playback state; driven by window.NSPlayer callbacks below.
let _nativePos = 0; // ms
let _nativeDur = 0; // ms
let _nativePlaying = false;
// Unified duration / position / seek helpers — use native state when available,
// fall back to HTMLAudioElement when not.
const _dur = () => _native() ? _nativeDur / 1000 : (S.audio.duration || 0);
const _pos = () => _native() ? _nativePos / 1000 : (S.audio.currentTime || 0);
const _seek = ms => _native() ? _nsBridge('seekTo', ms) : (S.audio.currentTime = ms / 1000);
// Safe NSBridge caller — never throws into JS.
function _nsBridge(method, ...args) {
try { window.NSBridge[method](...args); } catch(_) {}
}
// Convert a Navidrome song object to the NSBridge Track JSON schema.
function _trackToNative(s) {
return {
id: s.id,
url: streamUrl(s.id),
title: s.title || '',
artist: s.artist || '',
album: s.album || null,
artworkUrl: s.coverArt ? _cachedCoverUrl(s.coverArt, 512) : null,
durationMs: Math.round((s.duration || 0) * 1000),
};
}
// ─── NATIVE → JS CALLBACKS ────────────────────────────────────────────────
// window.NSPlayer is the surface that MusicService calls back into.
// Installed here so it exists before onPageFinished fires.
window.NSPlayer = {
// Called when MediaController connects (first load or WebView reload).
onReady() {
const raw = window.NSBridge?.getState();
const state = raw ? (JSON.parse(raw) || {}) : {};
const nativeLen = state.queueLength || 0;
if (nativeLen > 0 && nativeLen === S.queue.length) {
// Native still has the same queue (e.g. after a WebView reload) — re-sync UI.
S.qIdx = state.currentIndex ?? S.qIdx;
_nativePlaying = state.isPlaying || false;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(_nativePlaying);
renderQueue();
} else if (S.queue.length > 0) {
// Native queue is empty or stale — push JS-side state (restored from localStorage).
const tracks = S.queue.map(_trackToNative);
_nsBridge('setQueue', JSON.stringify(tracks), S.qIdx, false);
}
// Always sync settings.
_nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
_nsBridge('setShuffleMode', S.shuffle);
},
// Play/pause/buffer state changed.
onPlaybackStateChanged(stateJson) {
const state = JSON.parse(stateJson);
const prev = _nativePlaying;
_nativePlaying = state.isPlaying;
if (state.isPlaying !== prev) {
setPlayIcon(state.isPlaying);
document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying);
if (navigator.mediaSession)
navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused';
}
},
// Native moved to a different track (auto-advance, seek, or queue replace).
onTrackChanged(payloadJson) {
const payload = JSON.parse(payloadJson);
S.qIdx = payload.index;
const s = S.queue[S.qIdx];
if (!s) return;
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
// Radio mode: top up the queue when running low; push new songs to native.
if (S.radioMode && S.queue.length - S.qIdx < 5) {
const prevLen = S.queue.length;
fillRadioQueue().then(() => {
S.queue.slice(prevLen).forEach(t =>
_nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1)
);
});
}
},
// ~250 ms position tick while playing.
onPositionUpdate(posMs, durMs) {
_nativePos = posMs;
_nativeDur = durMs;
if (S._scrubbing) return;
const dur = durMs / 1000;
const cur = posMs / 1000;
if (dur <= 0) return;
const p = (cur / dur) * 100;
document.getElementById('progress-fill').style.width = p + '%';
document.getElementById('time-cur').textContent = formatDur(cur);
document.getElementById('time-total').textContent = formatDur(dur);
if (S.npOpen) {
const fill = document.getElementById('np-fill');
const curEl = document.getElementById('np-cur');
const totEl = document.getElementById('np-tot');
if (fill) fill.style.width = p + '%';
if (curEl) curEl.textContent = formatDur(cur);
if (totEl) totEl.textContent = formatDur(dur);
if (S.npLyricsMode) updateLyricsHighlight(posMs);
}
},
// Last track finished with repeat off.
onQueueEnded() {
_nativePlaying = false;
setPlayIcon(false);
if (S.radioMode) {
const prevLen = S.queue.length;
fillRadioQueue().then(() => {
const newTracks = S.queue.slice(prevLen);
newTracks.forEach(t => _nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1));
if (newTracks.length > 0) _nsBridge('play');
});
}
},
onError(message) { toast('Playback error: ' + message); },
};
// ─── COVER ART URL CACHE ───────────────────────────────────────────────────
// Stable URLs let the OS cache artwork images — without this every skip regenerates a new
// auth token URL, Samsung has to re-fetch the image, and the lock screen notification
// visually reloads. Defined here (top of file) so both preload and MediaSession can use it.
// visually reloads.
const _coverCache = new Map();
function _cachedCoverUrl(id, size) {
const key = `${id}:${size}`;
@@ -16,17 +153,15 @@ function _cachedCoverUrl(id, size) {
return _coverCache.get(key);
}
// ─── AUDIO SESSION — set once, never again ───
// Reassigning audioSession.type on every track change resets the OS audio session,
// which can cut background network briefly — exactly what causes Doze to kill autoplay.
// ─── AUDIO SESSION — set once, never again ───────────────────────────────
let _audioSessionSet = false;
function _setAudioSession() {
if (_audioSessionSet) return;
if (_native() || _audioSessionSet) return;
_audioSessionSet = true;
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
}
// ─── PRELOAD (resource hint keeps connection open through Doze gap) ───
// ─── PRELOAD (resource hint keeps connection open through Doze gap) ───────
let _preUrl = '';
let _preId = '';
let _preLink = null;
@@ -40,20 +175,19 @@ function _nextIdx() {
}
function _preloadNext() {
if (_native()) return; // ExoPlayer handles buffering natively
const ni = _nextIdx();
if(ni < 0) return;
const next = S.queue[ni];
if(!next || _preId === next.id) return;
_preId = next.id;
_preUrl = streamUrl(next.id); // generate URL once — reused by main element
_preUrl = streamUrl(next.id);
if(_preLink) _preLink.remove();
_preLink = document.createElement('link');
_preLink.rel = 'prefetch';
_preLink.as = 'fetch';
_preLink.href = _preUrl;
document.head.appendChild(_preLink);
// Pre-warm cover art so lock screen notification never cold-starts artwork on first skip.
// Uses _cachedCoverUrl so the same token is reused when updateMediaSession() runs later.
if(next.coverArt) {
[96, 300, 512].forEach(size => {
const img = new Image();
@@ -67,13 +201,22 @@ function _clearPreload() {
if(_preLink) { _preLink.remove(); _preLink = null; }
}
// ─── PLAYBACK ───
let _playGen = 0; // incremented on every playCurrent() — stale callbacks check this
// ─── PLAYBACK ─────────────────────────────────────────────────────────────
let _playGen = 0;
export function playQueue(songs, startIdx) {
S.queue = songs.map(s => ({...s}));
S.qIdx = startIdx;
S.qIdx = startIdx;
_clearPreload();
if (_native()) {
const tracks = S.queue.map(_trackToNative);
_nsBridge('setQueue', JSON.stringify(tracks), startIdx, true);
renderQueue();
if (S.radioMode) fillRadioQueue();
return;
}
playCurrent();
renderQueue();
if(S.radioMode) fillRadioQueue();
@@ -82,13 +225,21 @@ export function playQueue(songs, startIdx) {
export function playCurrent() {
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
const s = S.queue[S.qIdx];
if (_native()) {
// Update DOM immediately for snappy feedback; onTrackChanged will confirm.
S._trackChanging = true;
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
return;
}
const gen = ++_playGen;
S._trackChanging = true;
// Hold notification open with 'playing' state BEFORE touching metadata or src
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
// DOM update only — MediaSession metadata deferred until audio actually starts.
// Changing metadata during the HAVE_NOTHING window (after src swap) causes Chrome to
// signal "stopped" to Android, making Samsung close and re-open the lock screen card.
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
@@ -99,32 +250,23 @@ export function playCurrent() {
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
// Called once per transition when audio is confirmed playing
const _applySession = () => {
if (_playGen !== gen) return;
updateMediaSession(s);
setTimeout(_preloadNext, 2000);
};
// ── canplay-first recovery (armed BEFORE play() call) ──────────────────────
// canplay fires from the audio engine — not throttled by Android Doze or JS
// timer limits — so this is the only reliable wakeup on a locked screen.
// We arm it now so it's ready whether play() hangs, fails, or never resolves.
let _guardTimer = null;
let _canplayFn = null;
function _armCanplay() {
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
clearTimeout(_guardTimer);
_guardTimer = setTimeout(() => {
// canplay didn't fire in 10 s — Doze likely stalled the network.
// Try play() anyway; _onForeground() will retry if the user opens the app.
if (_playGen !== gen) return;
S._trackChanging = false;
S.audio.play().then(_applySession).catch(() => {});
}, 10000);
_canplayFn = () => {
clearTimeout(_guardTimer);
_canplayFn = null;
@@ -136,23 +278,19 @@ export function playCurrent() {
S.audio.addEventListener('canplay', _canplayFn, { once: true });
}
_armCanplay(); // arm before play() so we never miss the canplay event
_armCanplay();
_setAudioSession();
S.audio.play()
.then(() => {
// Immediate success — cancel canplay guard, apply session now
clearTimeout(_guardTimer);
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
_applySession();
})
.catch(err => {
if (_playGen !== gen) return;
// Any failure (AbortError, NotAllowedError, etc.): reset the element so
// the audio engine starts fresh and canplay fires reliably.
// _trackChanging stays true so load()'s pause event stays suppressed.
S.audio.src = src;
S.audio.load();
_armCanplay(); // re-arm: canplay may have already fired once before the error
_armCanplay();
});
}
@@ -200,7 +338,6 @@ function _crossfadeNpBg(cv) {
const nextEl = document.getElementById(`np-bg-${nextId}`);
if (!currEl || !nextEl) return;
nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none';
// Double rAF: wait for browser to paint the new bg (GPU decode) before fading
requestAnimationFrame(() => requestAnimationFrame(() => {
nextEl.style.opacity = '1';
currEl.style.opacity = '0';
@@ -240,8 +377,12 @@ export function _syncNpDirect(s) {
syncNpStar();
}
// ─── TRANSPORT ───
export function togglePlay() { if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); }
// ─── TRANSPORT ────────────────────────────────────────────────────────────
export function togglePlay() {
if (_native()) { _nsBridge('togglePlay'); return; }
if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause();
}
export function setPlayIcon(playing) {
document.getElementById('play-icon').innerHTML = playing
@@ -254,18 +395,18 @@ export function setPlayIcon(playing) {
const cover = document.getElementById('np-cover');
if(cover) cover.classList.toggle('playing', playing);
}
// Android native MediaSession bridge
if (window.Android) { try { window.Android.setPlaybackState(playing); } catch(_) {} }
}
export function prevSong() {
_clearPreload();
if (_native()) { _nsBridge('prev'); return; }
if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; }
if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
}
export function nextSong() {
_clearPreload();
if (_native()) { _nsBridge('next'); return; }
if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length);
else if(S.qIdx < S.queue.length - 1) S.qIdx++;
else if(S.repeat) S.qIdx = 0;
@@ -296,15 +437,18 @@ export function toggleShuffle() {
S.shuffle = !S.shuffle;
localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0');
_applyShuffleRepeat();
if (_native()) _nsBridge('setShuffleMode', S.shuffle);
}
export function toggleRepeat() {
S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false;
localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat);
_applyShuffleRepeat();
if (_native()) _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
}
// ─── RADIO ───
// ─── RADIO ────────────────────────────────────────────────────────────────
export function _applyRadioBtn() {
const btn = document.getElementById('btn-radio-mode');
if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; }
@@ -328,23 +472,17 @@ export async function fillRadioQueue() {
} catch {}
}
// ─── PROGRESS & SCRUBBING ───
let _androidPosTick = 0;
// ─── PROGRESS & SCRUBBING ─────────────────────────────────────────────────
export function updateProgress() {
if(_native()) return; // driven by NSPlayer.onPositionUpdate instead
if(!S.audio.duration || S._scrubbing) return;
syncMediaSessionPosition();
// Preload next track at 30s remaining so network stays alive through Doze gap
const rem = S.audio.duration - S.audio.currentTime;
if(rem > 0 && rem < 30 && !S._trackChanging) _preloadNext();
// Update Android native position every ~5 seconds to keep lock screen scrubber in sync
const now = Date.now();
if(now - _androidPosTick > 5000) {
_androidPosTick = now;
if (window.Android) { try { window.Android.updatePosition(S.audio.currentTime, S.audio.duration); } catch(_) {} }
}
const p = (S.audio.currentTime / S.audio.duration) * 100;
document.getElementById('progress-fill').style.width = p + '%';
document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime);
document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime);
document.getElementById('time-total').textContent = formatDur(S.audio.duration);
if(S.npOpen) {
const fill = document.getElementById('np-fill');
@@ -359,28 +497,35 @@ export function updateProgress() {
export function seekTo(e) {
const bar = document.getElementById('progress-bar');
if (!bar || !S.audio.duration) return;
const dur = _dur();
if (!bar || !dur) return;
const rect = bar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const fill = document.getElementById('progress-fill');
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
S.audio.currentTime = pos * S.audio.duration;
S._scrubbing = true;
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
S.audio.addEventListener('seeked', done, { once: true });
setTimeout(done, 1500);
if (_native()) {
_nsBridge('seekTo', Math.round(pos * _nativeDur));
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
setTimeout(done, 500);
} else {
S.audio.currentTime = pos * dur;
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
S.audio.addEventListener('seeked', done, { once: true });
setTimeout(done, 1500);
}
}
export function initProgressScrub() {
_initBarScrub(
document.getElementById('progress-bar'),
document.getElementById('progress-fill'),
pos => { document.getElementById('time-cur').textContent = formatDur(pos * S.audio.duration); }
pos => { document.getElementById('time-cur').textContent = formatDur(pos * _dur()); }
);
_initBarScrub(
document.getElementById('np-bar'),
document.getElementById('np-fill'),
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * S.audio.duration); }
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); }
);
}
@@ -389,13 +534,18 @@ function _initBarScrub(bar, fill, onMove) {
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); }
function moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); }
function endScrub(pos) {
fill.style.width = (pos * 100) + '%'; // pin visual position before re-enabling transition
if(S.audio.duration) {
S.audio.currentTime = pos * S.audio.duration;
function endScrub(pos) {
fill.style.width = (pos * 100) + '%';
const dur = _dur();
if (_native()) {
_nsBridge('seekTo', Math.round(pos * _nativeDur));
S._scrubbing = false;
requestAnimationFrame(() => { fill.style.transition = ''; });
} else if (dur) {
S.audio.currentTime = pos * dur;
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); };
S.audio.addEventListener('seeked', done, { once: true });
setTimeout(done, 1500); // fallback if seeked never fires
setTimeout(done, 1500);
} else {
S._scrubbing = false;
fill.style.transition = '';
@@ -419,24 +569,15 @@ export function setVolume(v) {
S.previewAudio.volume = vol;
const absAudio = document.getElementById('abs-audio');
if (absAudio) absAudio.volume = vol;
// sync all volume sliders
document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; });
if (_native()) _nsBridge('setVolume', vol);
}
// ─── MEDIA SESSION ───
export function updateMediaSession(s) {
// Android native MediaSession bridge — use 512px art for high-DPI screens
if (window.Android) {
try {
window.Android.updateTrack(
s.title || '',
s.artist || '',
s.coverArt ? coverUrl(s.coverArt, 512) : '',
s.duration || 0
);
} catch(_) {}
}
// ─── MEDIA SESSION ────────────────────────────────────────────────────────
export function updateMediaSession(s) {
// When native is active, Media3 owns the system notification — don't touch it.
if (_native()) return;
if(!navigator.mediaSession) return;
const artwork = s.coverArt ? [
{ src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' },
@@ -444,16 +585,17 @@ export function updateMediaSession(s) {
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
] : [];
navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
navigator.mediaSession.setActionHandler('play', togglePlay);
navigator.mediaSession.setActionHandler('pause', togglePlay);
navigator.mediaSession.setActionHandler('play', togglePlay);
navigator.mediaSession.setActionHandler('pause', togglePlay);
navigator.mediaSession.setActionHandler('previoustrack', prevSong);
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
}
export function syncMediaSessionPosition() {
if (_native()) return;
if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return;
const now = Date.now();
if(now - S._msPosLast < 1000) return;
@@ -461,7 +603,8 @@ export function syncMediaSessionPosition() {
try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {}
}
// ─── STAR ───
// ─── STAR ─────────────────────────────────────────────────────────────────
export async function toggleStar(song, btn) {
try {
await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`);
@@ -506,7 +649,8 @@ export function npToggleStar() {
.catch(e => toast('Fehler: ' + e.message));
}
// ─── QUEUE PANEL ───
// ─── QUEUE PANEL ──────────────────────────────────────────────────────────
let _renderedQueueKey = '';
export function renderQueue() {
@@ -515,7 +659,6 @@ export function renderQueue() {
const queueKey = S.queue.map(s => s.id).join(',');
if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) {
// Queue unchanged — only update active class in-place (no image reload)
el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx));
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
return;
@@ -541,7 +684,8 @@ export function toggleQueue() {
if(S.queueOpen) renderQueue();
}
// ─── DOWNLOAD ───
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
export function dlSongLocal(s) {
const a = document.createElement('a');
a.href = downloadUrl(s.id);
@@ -554,11 +698,15 @@ export function downloadCurrent() {
if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]);
}
// ─── PREVIEW ───
// ─── PREVIEW ──────────────────────────────────────────────────────────────
export function playPreview(track) {
if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; }
const wasPlaying = !S.audio.paused;
if(wasPlaying) S.audio.pause();
const wasPlaying = _native() ? _nativePlaying : !S.audio.paused;
if(wasPlaying) {
if (_native()) _nsBridge('pause');
else S.audio.pause();
}
S.previewCurrent = track;
S.previewCurrent._resumeMain = wasPlaying;
S.previewAudio.volume = S.audio.volume;
@@ -578,7 +726,10 @@ export function stopPreview(resume) {
document.getElementById('preview-bar').classList.remove('visible');
document.getElementById('preview-fill').style.width = '0%';
S.previewCurrent = null;
if(shouldResume && S.audio.src) S.audio.play();
if (shouldResume) {
if (_native()) _nsBridge('play');
else if (S.audio.src) S.audio.play();
}
}
export function updatePreviewProgress() {
@@ -586,7 +737,8 @@ export function updatePreviewProgress() {
document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%';
}
// ─── RESTORE LAST PLAYED ───
// ─── RESTORE LAST PLAYED ──────────────────────────────────────────────────
export function _restoreLastPlayed() {
try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
@@ -610,11 +762,9 @@ export function _restoreLastPlayed() {
art.innerHTML = cv ? `<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 {}
}