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
+242
-92
@@ -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 {}
|
||||
}
|
||||
Reference in new issue
Block a user