Files
NovaSonic/src/player/player.js
T
HoneyandClaude Opus 4.8 1d98b273e7 fix(web player + stats modal): no auto-resume after user pause; stable stats modal
Player: opening the PWA no longer auto-plays when the user had paused. Pausing via
the media notification while backgrounded routes through togglePlay, which now sets
S._userPaused; _onForeground only auto-resumes an INVOLUNTARY background stop
(_wasPlayingBeforeHide && !_userPaused). playCurrent clears the flag on a new track.
Involuntary-stop/freeze recovery on foreground is preserved.

Stats modal: no more small→big flash when switching period/tab — the body keeps a
stable min-height and retains its current content until the new data arrives (only
the first open shows 'Lade…'); a per-request token drops out-of-order responses.
SW cache -> ns-v71.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 18:06:18 +02:00

1390 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from '../state.js';
import { esc, formatDur, toast } from '../utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
const HAS_BRIDGE = typeof window.NSBridge !== 'undefined';
// Track native playback state; driven by window.NSPlayer callbacks below.
let _nativePos = 0; // ms
let _nativePlaying = false;
let _lastPositionMs = 0; // ms — set by onPositionUpdate, used for prev() decision
let _lastDurationMs = 0; // ms — set by onPositionUpdate, used for seek calculations
let _readyTs = 0; // timestamp of last onReady — used to suppress spurious onQueueEnded
let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ignored before this
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(s) {
const id = s?.id;
if (!id || _scrobbleId === String(id)) return;
_cancelScrobble();
_scrobbleId = String(id);
const mode = S.cfg.scrobbleMode || '15s';
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
let delay;
const dur = (s?.duration || 0) * 1000;
switch (mode) {
case '30s': delay = 30_000; break;
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
case '2/3': delay = dur > 0 ? Math.floor(dur * 2 / 3) : 60_000; break;
default: delay = 15_000; break;
}
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) {
api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
_scrobbleId = null;
}, delay);
}
function _cancelScrobble() {
clearTimeout(_scrobbleTimer);
_scrobbleTimer = null;
_scrobbleId = null;
}
function _scrobbleNow(s) {
if (s) {
api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
}
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
// from the delayed "played" scrobble above, which counts the play for stats / Last.fm.
function _scrobbleNowPlaying(s) {
if (s) api('scrobble', `&id=${s.id}&submission=false`).catch(() => {});
}
// Unified duration / position / seek helpers — use native state when available,
// fall back to HTMLAudioElement when not.
const _dur = () => HAS_BRIDGE ? _lastDurationMs / 1000 : (S.audio.duration || 0);
const _pos = () => HAS_BRIDGE ? _nativePos / 1000 : (S.audio.currentTime || 0);
const _seek = ms => HAS_BRIDGE ? _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 single Navidrome song to the NSBridge Track 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 ? coverUrl(s.coverArt, 1024) : null,
durationMs: Math.floor((s.duration || 0) * 1000),
};
}
// Build the NSBridge Track JSON array from S.queue.
const buildTrackList = () => S.queue.map(_trackToNative);
// Single-track JSON for setQueue — ExoPlayer always holds exactly one item.
const _nativeSingle = s => JSON.stringify([_trackToNative(s)]);
// Persist the current track to Android SharedPreferences so MusicService can
// pre-warm ExoPlayer on the next cold start before the WebView finishes loading.
function _persistLastPlayed(s) {
if (!HAS_BRIDGE || !s) return;
try { window.NSBridge.saveLastPlayed(JSON.stringify(_trackToNative(s))); } catch (_) {}
}
// Reload native player with just the current song. The single-track model means
// ExoPlayer is never responsible for queue order — JS owns that entirely.
function _pushQueueIfBridge() {
if (!HAS_BRIDGE) return;
const s = S.queue[S.qIdx];
if (!s) return;
try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
}
// Radio top-ups: add to S.queue only. ExoPlayer gets the new song when JS naturally
// picks it via nextSong() → setQueue([single], 0, true). No native queue manipulation.
export function nativeInsertAt(_song, _position) { /* no-op in single-track model */ }
// ─── 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() {
// Mark bridge as ready: onQueueEnded fired before this point came from a previous
// session (song finished while backgrounded) and must not advance the queue.
_bridgeReady = true;
_readyTs = Date.now();
if (HAS_BRIDGE && S.queue.length > 0) {
try {
const raw = window.NSBridge.getState();
const state = raw ? (JSON.parse(raw) || {}) : {};
const nativeLen = state.queueLength || 0;
const nativePlaying = state.isPlaying || false;
const nativeEnded = state.isEnded || false;
const nativeId = state.currentId || '';
// Single-track model: native index is always 0, meaningless for queue position.
// Match by song ID against S.queue[S.qIdx] — the JS-side current position.
const currentSong = S.queue[S.qIdx];
const idMatch = !nativeId || !currentSong?.id ||
String(nativeId) === String(currentSong.id);
if (nativeLen > 0 && nativePlaying) {
if (idMatch) {
// Native is playing our current song — re-sync UI without touching S.qIdx.
_nativePlaying = true;
_nativePos = state.positionMs || 0;
if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); }
setPlayIcon(true);
renderQueue();
} else {
// Native advanced to a different song while backgrounded — find it in the queue.
const found = nativeId ? S.queue.findIndex(s => String(s.id) === String(nativeId)) : -1;
if (found >= 0) {
S.qIdx = found;
_nativePlaying = true;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(true);
renderQueue();
} else {
// Playing a song not in our queue (radio advanced past known tracks) — clear.
S.queue = [];
S.qIdx = 0;
}
}
} else if (nativeLen > 0 && nativeEnded && idMatch) {
// Song finished while backgrounded and ExoPlayer is in STATE_ENDED.
// Reset to position 0 so the user can press play to replay — do NOT advance queue.
_nativePlaying = false;
_nativePos = 0;
if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); }
setPlayIcon(false);
renderQueue();
_nsBridge('seekTo', 0);
} else if (nativeLen > 0 && !nativePlaying && idMatch) {
// Paused, song matches — re-sync UI state only.
_nativePlaying = false;
_nativePos = state.positionMs || 0;
if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); }
setPlayIcon(false);
renderQueue();
} else {
// Native is idle/empty or has a mismatched paused song — push current (single-track model).
const s = S.queue[S.qIdx];
if (s) window.NSBridge.setQueue(_nativeSingle(s), 0, false);
}
} catch (e) {}
}
// Always sync shuffle/repeat settings.
// setShuffleMode is always false — we pre-shuffle the JS array ourselves and
// traverse sequentially, so ExoPlayer's internal shuffle must stay off.
_nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off'); // 'all' handled in JS
_nsBridge('setShuffleMode', false);
},
// Play/pause/buffer state changed.
onPlaybackStateChanged(stateJson) {
try {
const state = JSON.parse(stateJson);
const prev = _nativePlaying;
_nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
if (state.isPlaying && S.previewCurrent) stopPreview(false);
// Show pause icon while buffering (isPlaying=false but isBuffering=true) so the
// button doesn't flash "play" during the brief re-buffer between tracks.
const showPlaying = state.isPlaying || state.isBuffering;
if (state.isPlaying !== prev) {
setPlayIcon(showPlaying);
document.getElementById('player-art')?.classList.toggle('spinning', showPlaying);
if (navigator.mediaSession)
navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused';
}
} catch (e) {}
},
// Native started playing a track (confirmation of our setQueue call).
// With the single-track model, JS already updated S.qIdx before calling setQueue,
// so nothing needs to change here — just trigger scrobble and radio top-up.
onTrackChanged(payloadJson) {
try {
const s = S.queue[S.qIdx];
if (!s) return;
_scheduleScrobble(s);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {}
},
// ~250 ms position tick while playing.
onPositionUpdate(posMs, durMs) {
_nativePos = posMs;
_lastPositionMs = posMs;
_lastDurationMs = durMs;
if (S._scrubbing) return;
// Single-track model: no queue drift possible, reconciliation tick removed.
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);
}
},
// Single track finished — JS drives what plays next.
onQueueEnded() {
// Ignore if onReady hasn't fired yet: the song ended while the app was backgrounded
// and the callback arrived before the new page session is fully connected.
// Also ignore the brief window right after onReady (stale STATE_ENDED replay).
if (!_bridgeReady || Date.now() - _readyTs < 1500) return;
_nativePlaying = false;
setPlayIcon(false);
if (S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
onEnded(); // handles repeat, radio top-up, and queue advance
},
// Notification next/prev buttons: intercept at MediaSession level so JS controls queue.
onNotificationNext() { nextSong(); },
onNotificationPrev() { prevSong(); },
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.
const _coverCache = new Map();
function _cachedCoverUrl(id, size) {
const key = `${id}:${size}`;
if (!_coverCache.has(key)) _coverCache.set(key, coverUrl(id, size));
return _coverCache.get(key);
}
// ─── AUDIO SESSION — set once, never again ───────────────────────────────
let _audioSessionSet = false;
function _setAudioSession() {
if (HAS_BRIDGE || _audioSessionSet) return;
_audioSessionSet = true;
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
}
// ─── PRELOAD — the standby audio element buffers the next track ──────────────
// Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to
// it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the
// new artwork is ready too. Falls back to a source-swap when the next track isn't preloaded.
let _preUrl = '';
let _preId = '';
/** The audio element that is NOT currently active (or null if there's only one). */
function _standbyEl() {
const els = S._audioEls;
if (!els || els.length < 2) return null;
return els.find(e => e !== S.audio) || null;
}
function _nextIdx() {
if(S.repeat === 'one') return S.qIdx;
if(S.qIdx + 1 < S.queue.length) return S.qIdx + 1;
if(S.repeat === 'all') return 0;
return -1;
}
function _preloadNext() {
if (HAS_BRIDGE) return;
const ni = _nextIdx();
if(ni < 0) return;
const next = S.queue[ni];
if(!next || _preId === next.id) return;
const standby = _standbyEl();
if(!standby) return;
_preId = next.id;
_preUrl = streamUrl(next.id);
standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly
try { standby.load(); } catch {}
if(next.coverArt) {
[256, 512, 1024].forEach(size => {
const img = new Image();
img.src = _cachedCoverUrl(next.coverArt, size);
});
}
}
function _clearPreload() {
_preId = ''; _preUrl = '';
const standby = _standbyEl();
if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} }
}
// ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
// Records the qIdx before each forward advance so prevSong() can retrace the
// actual play order instead of blindly decrementing (which is wrong in shuffle).
const _playHist = [];
function _histPush(idx) {
if (_playHist[_playHist.length - 1] === idx) return;
_playHist.push(idx);
if (_playHist.length > 50) _playHist.shift();
}
function _histClear() { _playHist.length = 0; }
// ─── PLAYBACK ─────────────────────────────────────────────────────────────
let _playGen = 0;
// Incremented on every playQueue() call so in-flight fillRadioQueue callbacks
// from the previous queue can detect they're stale and bail out.
let _queueGen = 0;
export function playQueue(songs, startIdx, opts = {}) {
_queueGen++;
_radioFilling = false; // cancel any in-flight radio fill for the old queue
S._origQueue = null;
_histClear();
// Shuffle: fully random order, no pinning — startIdx is ignored.
// Both web and Android traverse this fixed shuffled order sequentially.
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
S._origQueue = songs.map(s => ({...s}));
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s}));
S.qIdx = 0;
} else {
S.queue = songs.map(s => ({...s}));
S.qIdx = startIdx;
}
_clearPreload();
if (HAS_BRIDGE) {
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
renderQueue();
// Single-track model: ExoPlayer always holds exactly one song.
// JS owns all queue logic — no desync possible.
const single = s ? _nativeSingle(s) : null;
requestAnimationFrame(() => {
if (single) {
try { window.NSBridge.setQueue(single, 0, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
}
if (S.radioMode) fillRadioQueue();
});
return;
}
playCurrent();
renderQueue();
if(S.radioMode) fillRadioQueue();
}
export function playCurrent() {
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
S._userPaused = false; // starting a track is intent to play — clear any stale pause intent
const s = S.queue[S.qIdx];
if (HAS_BRIDGE) {
S._trackChanging = true;
_nativePlaying = true;
_resetProgressUI();
updatePlayerUI(s, true);
setPlayIcon(true);
onNpSongChange(s.id);
renderQueue();
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
return;
}
const gen = ++_playGen;
S._trackChanging = true;
_resetProgressUI();
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
updatePlayerUI(s, true);
updateMediaSession(s);
onNpSongChange(s.id);
renderQueue();
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
_scheduleScrobble(s);
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
const _applySession = () => {
if (_playGen !== gen) return;
updateMediaSession(s);
setTimeout(_preloadNext, 2000);
};
// ── Instant skip: if the standby element already buffered THIS track, hand playback over
// to it (no source reload → no ~1s wait). The new title/cover were already set above. ──
const standby = _standbyEl();
if (standby && _preId === s.id && standby.readyState >= 2) {
const old = S.audio;
standby.currentTime = 0;
standby.volume = old.volume; standby.muted = old.muted; standby.playbackRate = old.playbackRate || 1;
S.audio = standby; // the active element is now the preloaded one
_preId = ''; _preUrl = '';
try { old.pause(); } catch {} // 'pause' is ignored for the now-inactive element
_setAudioSession();
S.audio.play()
.then(() => { S._trackChanging = false; _applySession(); })
.catch(() => {
// Handoff playback failed — recover with a fresh source load on this element.
if (_playGen !== gen) return;
S.audio.src = streamUrl(s.id);
try { S.audio.load(); } catch {}
S.audio.play().then(() => { S._trackChanging = false; _applySession(); }).catch(_resetFlag);
});
return;
}
// ── Fallback: classic source swap on the current element (unchanged) ──
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
let _guardTimer = null;
let _canplayFn = null;
function _armCanplay() {
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
clearTimeout(_guardTimer);
_guardTimer = setTimeout(() => {
if (_playGen !== gen) return;
S._trackChanging = false;
S.audio.play().then(_applySession).catch(() => {});
}, 10000);
_canplayFn = () => {
clearTimeout(_guardTimer);
_canplayFn = null;
if (_playGen !== gen) return;
if (!S.audio.paused) { _applySession(); return; }
_setAudioSession();
S.audio.play().then(_applySession).catch(_resetFlag);
};
S.audio.addEventListener('canplay', _canplayFn, { once: true });
}
_armCanplay();
_setAudioSession();
S.audio.play()
.then(() => {
clearTimeout(_guardTimer);
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
_applySession();
})
.catch(err => {
if (_playGen !== gen) return;
S.audio.src = src;
S.audio.load();
_armCanplay();
});
}
function _updatePlayingHighlights() {
const id = String(S.queue[S.qIdx]?.id ?? '');
document.querySelectorAll('.song-item[data-song-id]').forEach(el => {
el.classList.toggle('playing', el.dataset.songId === id);
});
}
// Returns [{name, id}] using Navidrome's resolved artists when available,
// otherwise splits the artist string on ';' only (safe for names like Au/Ra).
export function parseArtists(s) {
if (Array.isArray(s.artists) && s.artists.length > 1) return s.artists;
return (s.artist || '').split(/\s*;\s*/).filter(Boolean)
.map((name, i) => ({ name, id: i === 0 ? (s.artistId || '') : '' }));
}
export function updatePlayerUI(s, _skipMediaSession = false) {
document.getElementById('player-title').textContent = dispTitle(s);
const artistParts = dispArtistParts(s, parseArtists);
document.getElementById('player-artist-name').innerHTML = artistParts.length
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="color:var(--text3)"> • </span>')
: '—';
document.getElementById('player-album-name').textContent = dispAlbum(s);
const art = document.getElementById('player-art');
const cv = coverUrl(s.coverArt, 256);
const existingImg = art.querySelector('img');
if (cv && existingImg) { if (existingImg.src !== cv) existingImg.src = cv; }
else 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`;
if (!_skipMediaSession) updateMediaSession(s);
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
_persistLastPlayed(s);
try {
localStorage.setItem('ns_last_played', JSON.stringify({
song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId,
artistId:s.artistId, coverArt:s.coverArt, duration:s.duration, _starred:s._starred||false,
artists:s.artists||[] },
queue: S.queue.map(t => ({ id:t.id, title:t.title, artist:t.artist, album:t.album,
albumId:t.albumId, artistId:t.artistId, coverArt:t.coverArt,
duration:t.duration, _starred:t._starred||false,
artists:t.artists||[] })),
qIdx: S.qIdx,
}));
} catch {}
_syncNpDirect(s);
_updatePlayingHighlights();
// Keep the NP cover element pointing at the CURRENT song even while the overlay is closed,
// so opening it after rapid skips never shows a stale cover (it also preloads in the background).
const _npCv = coverUrl(s.coverArt, 1024);
const _npCover = document.getElementById('np-cover');
const _npImg = _npCover?.querySelector('img');
if (_npCv) {
if (_npImg) { if (_npImg.src !== _npCv) _npImg.src = _npCv; }
else if (_npCover) _npCover.innerHTML = `<img src="${_npCv}" style="width:100%;height:100%;object-fit:cover">`;
}
// Pre-warm the next song's 1024 cover so auto-advance NP swap is instant from cache
const _nextForPreload = S.qIdx + 1 < S.queue.length ? S.queue[S.qIdx + 1] : null;
if (_nextForPreload?.coverArt) {
const _nextCv = coverUrl(_nextForPreload.coverArt, 1024);
if (_nextCv) { const _preN = new Image(); _preN.src = _nextCv; }
}
}
// ── Solid cover-color background ────────────────────────────────────────────
// Fetch the cover as a blob (bypasses canvas CORS taint), draw on a tiny
// canvas, find the most vivid pixel, darken it to a moody solid background.
let _npColorActiveUrl = '';
async function _extractAndApplyColor(cv) {
if (!cv || !S.cfg?.coverColorBg) return;
if (cv === _npColorActiveUrl) return;
_npColorActiveUrl = cv;
try {
const res = await fetch(cv, { signal: AbortSignal.timeout(6000) });
const blob = await res.blob();
const burl = URL.createObjectURL(blob);
const img = await new Promise((ok, err) => {
const i = new Image(); i.onload = () => ok(i); i.onerror = err; i.src = burl;
});
URL.revokeObjectURL(burl);
if (cv !== _npColorActiveUrl) return; // superseded by next song
const c = document.createElement('canvas'); c.width = c.height = 24;
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, 24, 24);
const d = ctx.getImageData(0, 0, 24, 24).data;
let br = 20, bg2 = 20, bb = 40, best = 0;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i+1], b = d[i+2];
const mx = Math.max(r,g,b), mn = Math.min(r,g,b);
const sat = mx > 0 ? (mx-mn)/mx : 0;
const lum = (r+g+b)/3;
const score = sat * 2 + (lum > 30 && lum < 220 ? 1 : 0);
if (score > best) { best = score; br = r; bg2 = g; bb = b; }
}
// Darken significantly for a moody solid bg
const el = document.getElementById('np-overlay');
if (el && S.cfg?.coverColorBg)
el.style.setProperty('--np-solid', `${Math.round(br*.28)},${Math.round(bg2*.28)},${Math.round(bb*.28)}`);
} catch {}
}
export function updateNpColorMode() {
const el = document.getElementById('np-overlay');
if (!el) return;
const on = !!S.cfg?.coverColorBg;
el.classList.toggle('dynamic-color', on);
if (!on) { el.style.removeProperty('--np-solid'); _npColorActiveUrl = ''; }
else if (_npBgLastUrl) _extractAndApplyColor(_npBgLastUrl);
}
let _npBgCurr = 'a';
let _npBgLastUrl = '';
function _crossfadeNpBg(cv) {
if ((cv || '') === _npBgLastUrl) return;
_npBgLastUrl = cv || '';
if (S.cfg?.coverColorBg) _extractAndApplyColor(cv || '');
const doFade = () => {
const currEl = document.getElementById(`np-bg-${_npBgCurr}`);
const nextId = _npBgCurr === 'a' ? 'b' : 'a';
const nextEl = document.getElementById(`np-bg-${nextId}`);
if (!currEl || !nextEl) return;
nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none';
requestAnimationFrame(() => requestAnimationFrame(() => {
nextEl.style.opacity = '1';
currEl.style.opacity = '0';
_npBgCurr = nextId;
}));
};
if (!cv) { doFade(); return; }
const img = new Image();
img.onload = doFade;
img.onerror = doFade;
img.src = cv;
}
export function _syncNpDirect(s) {
if(!S.npOpen || !s) return;
const cv = coverUrl(s.coverArt, 1024);
const cover = document.getElementById('np-cover');
if (cover) {
const existingImg = cover.querySelector('img');
if (cv && existingImg) { if (existingImg.src !== cv) existingImg.src = cv; }
else cover.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
}
_crossfadeNpBg(cv);
const t = document.getElementById('np-title'); if(t) t.textContent = dispTitle(s) || '—';
const an = document.getElementById('np-artist-name');
if(an) {
const parts = dispArtistParts(s, parseArtists);
an.innerHTML = parts.length
? parts.map(a => `<span style="cursor:pointer" onclick="closeNowPlaying();s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="opacity:.5"> • </span>')
: '—';
}
const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = dispAlbum(s);
syncNpStar();
}
// Bump _playGen so any stale canplay/guard callbacks from the last playCurrent()
// bail out and don't call S.audio.play() after ABS has taken over audio.
export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
// ─── TRANSPORT ────────────────────────────────────────────────────────────
export function togglePlay() {
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
if(S.audio.src) {
if (S.audio.paused) { S._userPaused = false; S.audio.play(); }
else { S._userPaused = true; S.audio.pause(); _cancelScrobble(); }
}
}
export const isPlaying = () => HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
export function setPlayIcon(playing) {
document.getElementById('play-icon').innerHTML = playing
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
: '<polygon points="5 3 19 12 5 21 5 3"/>';
if(S.npOpen) {
document.getElementById('np-play-icon').innerHTML = playing
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
: '<polygon points="5 3 19 12 5 21 5 3"/>';
const cover = document.getElementById('np-cover');
if(cover) cover.classList.toggle('playing', playing);
}
}
function _computeNextIdx() {
if (S.repeat === 'one') return S.qIdx;
// No special shuffle case — queue is already in shuffled order when S.shuffle is on.
if (S.qIdx + 1 < S.queue.length) return S.qIdx + 1;
if (S.repeat === 'all') return 0;
return -1;
}
function _resetProgressUI() {
if (HAS_BRIDGE) _nativePos = 0;
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('time-cur').textContent = formatDur(0);
if (S.npOpen) {
const fill = document.getElementById('np-fill');
const cur = document.getElementById('np-cur');
if (fill) fill.style.width = '0%';
if (cur) cur.textContent = formatDur(0);
}
}
export function prevSong() {
_clearPreload();
if (HAS_BRIDGE) {
if (_lastPositionMs > 3000) {
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
_resetProgressUI();
_nsBridge('seekTo', 0);
return;
}
// Pop history to retrace actual play order (correct in shuffle where next jumps randomly).
// Fall back to S.qIdx-1 when history is empty (e.g. at the very start of a queue).
const prevIdx = _playHist.length > 0 ? _playHist.pop() : (S.qIdx > 0 ? S.qIdx - 1 : -1);
if (prevIdx >= 0 && prevIdx < S.queue.length) {
S.qIdx = prevIdx;
const s = S.queue[S.qIdx];
if (s) {
_resetProgressUI();
updatePlayerUI(s, true);
syncNowPlaying();
onNpSongChange(s.id);
renderQueue();
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
}
}
return;
}
if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; }
if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
}
export function nextSong() {
_clearPreload();
if (HAS_BRIDGE) {
const ni = _computeNextIdx();
if (ni >= 0 && ni < S.queue.length) {
_histPush(S.qIdx);
S.qIdx = ni;
const s = S.queue[S.qIdx];
if (s) {
_resetProgressUI();
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
// Single-track: replace native player with the new song. No seekToIndex needed.
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
}
} else {
// End of queue, no repeat — stop.
_nativePlaying = false;
setPlayIcon(false);
}
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
return;
}
if(S.qIdx < S.queue.length - 1) S.qIdx++;
else if(S.repeat) S.qIdx = 0;
else if(S.radioMode) { fillRadioQueue().then(() => { if(S.qIdx < S.queue.length-1) { S.qIdx++; playCurrent(); } }); return; }
else return;
if(S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
playCurrent();
}
export function onEnded() {
if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; }
if (!HAS_BRIDGE && S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
_cancelScrobble();
nextSong();
}
export function _applyShuffleRepeat() {
document.getElementById('btn-shuffle')?.classList.toggle('active', S.shuffle);
document.getElementById('np-shuffle-btn')?.classList.toggle('active', S.shuffle);
document.getElementById('badge-shuffle')?.classList.toggle('on', S.shuffle);
document.getElementById('btn-repeat')?.classList.toggle('active', S.repeat !== false);
document.getElementById('btn-repeat')?.classList.toggle('repeat-one', S.repeat === 'one');
const nb = document.getElementById('np-repeat-btn');
if(nb) { nb.classList.toggle('active', S.repeat !== false); nb.classList.toggle('repeat-one', S.repeat === 'one'); }
const badge = document.getElementById('badge-repeat');
if(badge) { badge.classList.toggle('on', S.repeat !== false); badge.textContent = S.repeat === 'one' ? '🔂 Einmal' : '🔁 Repeat'; }
}
export function toggleShuffle() {
S.shuffle = !S.shuffle;
localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0');
if (S.queue.length > 1) {
if (S.shuffle) {
const cur = S.queue[S.qIdx];
S._origQueue = [...S.queue];
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5);
S.queue = [cur, ...rest];
S.qIdx = 0;
} else if (S._origQueue) {
const cur = S.queue[S.qIdx];
S.queue = S._origQueue;
S._origQueue = null;
const idx = S.queue.findIndex(s => s.id === cur?.id);
S.qIdx = idx >= 0 ? idx : 0;
}
renderQueue();
// Re-send the reordered queue to native so ExoPlayer's order matches JS.
if (HAS_BRIDGE) {
const s = S.queue[S.qIdx];
if (s) try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
catch(e) { console.warn('NSBridge.setQueue failed', e); }
}
}
_applyShuffleRepeat();
}
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();
// Single-track model: native repeat is only used for 'one'; 'all' is handled in JS.
if (HAS_BRIDGE) _nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off');
}
// ─── 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)' : ''; }
const nb = document.getElementById('np-radio-btn');
if(nb) { nb.classList.toggle('active', S.radioMode); nb.style.color = S.radioMode ? 'var(--blue)' : ''; }
}
export async function toggleRadioMode() {
S.radioMode = !S.radioMode;
localStorage.setItem('ns_radio', S.radioMode ? '1' : '0');
_applyRadioBtn();
if(S.radioMode) await fillRadioQueue();
}
let _radioFilling = false;
export async function fillRadioQueue() {
if (!S.radioMode || _radioFilling) return;
_radioFilling = true;
const gen = _queueGen;
try {
const seed = S.queue[S.qIdx];
let newSongs = [];
// 1. Try similar songs seeded from the current track
if (seed?.id) {
const r = await api('getSimilarSongs2', `&id=${seed.id}&count=20`);
newSongs = r.similarSongs2?.song || [];
}
if (gen !== _queueGen) return;
// 2. Fall back to random if similar songs unavailable or empty
if (!newSongs.length) {
const r = await api('getRandomSongs', '&size=20');
newSongs = r.randomSongs?.song || [];
}
if (gen !== _queueGen) return;
if (!newSongs.length) return;
const recentIds = new Set(S.queue.slice(-50).map(s => s.id));
const deduped = newSongs.filter(s => !recentIds.has(s.id));
if (deduped.length === 0) {
// All similar songs recently played (small library) — try random as final fallback
if (gen !== _queueGen) return;
const rr = await api('getRandomSongs', '&size=20');
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
// If random is also exhausted, allow repeats to keep music playing
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5);
} else {
newSongs = deduped;
}
if (gen !== _queueGen) return;
S.queue.push(...newSongs);
renderQueue();
// Single-track model: songs land in S.queue; JS picks them up via nextSong(). No native push needed.
} catch {
} finally {
_radioFilling = false;
}
}
// ─── PROGRESS & SCRUBBING ─────────────────────────────────────────────────
export function updateProgress() {
if(HAS_BRIDGE) return; // driven by NSPlayer.onPositionUpdate instead
if(!S.audio.duration || S._scrubbing) return;
syncMediaSessionPosition();
const rem = S.audio.duration - S.audio.currentTime;
if(rem > 0 && rem < 60 && !S._trackChanging) _preloadNext();
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-total').textContent = formatDur(S.audio.duration);
if(S.npOpen) {
const fill = document.getElementById('np-fill');
const cur = document.getElementById('np-cur');
const tot = document.getElementById('np-tot');
if(fill) fill.style.width = p + '%';
if(cur) cur.textContent = formatDur(S.audio.currentTime);
if(tot) tot.textContent = formatDur(S.audio.duration);
if(S.npLyricsMode) updateLyricsHighlight(S.audio.currentTime * 1000);
}
}
export function seekTo(e) {
const bar = document.getElementById('progress-bar');
const dur = _dur();
if (!bar || !dur) return;
const rect = bar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const fill = document.getElementById('progress-fill');
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
S._scrubbing = true;
if (HAS_BRIDGE) {
_nsBridge('seekTo', Math.round(pos * _lastDurationMs));
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() {
// Bottom mini-bar: mouse-only (touch disabled — too easy to accidentally seek on mobile).
_initBarScrub(
document.getElementById('progress-bar'),
document.getElementById('progress-fill'),
pos => { document.getElementById('time-cur').textContent = formatDur(pos * _dur()); },
/* noTouch= */ true
);
_initBarScrub(
document.getElementById('np-bar'),
document.getElementById('np-fill'),
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); }
);
}
// Export for lyrics / other callers that need to seek by ms.
export function seekToMs(ms) {
if (HAS_BRIDGE) {
_nsBridge('seekTo', Math.round(ms));
} else {
S.audio.currentTime = ms / 1000;
}
}
// ─── DESKTOP KEYBOARD SHORTCUTS ───
// Space = play/pause · ←/→ = seek 5s · Shift+←/→ = prev/next · ↑/↓ = volume.
// Skipped while typing in an input so it never hijacks the search/playlist fields.
export function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return; // leave OS/browser combos alone
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable)) return;
switch (e.code) {
case 'Space':
// Don't hijack Space when a button/link is focused — it would double-fire.
if (t && (t.tagName === 'BUTTON' || t.tagName === 'A' ||
t.closest?.('button, a, [role="button"]'))) return;
e.preventDefault();
togglePlay();
break;
case 'ArrowRight':
e.preventDefault();
if (e.shiftKey) nextSong(); else _seekBy(5);
break;
case 'ArrowLeft':
e.preventDefault();
if (e.shiftKey) prevSong(); else _seekBy(-5);
break;
case 'ArrowUp':
e.preventDefault();
_bumpVolume(5);
break;
case 'ArrowDown':
e.preventDefault();
_bumpVolume(-5);
break;
}
});
}
function _seekBy(deltaSec) {
const dur = _dur();
if (!dur) return;
seekToMs(Math.max(0, Math.min(dur, _pos() + deltaSec)) * 1000);
}
function _bumpVolume(deltaSlider) {
// setVolume takes a 0–100 slider value (squared internally); recover it from the actual volume.
const cur = Math.round(Math.sqrt(Math.max(0, S.audio.volume || 0)) * 100);
setVolume(Math.max(0, Math.min(100, cur + deltaSlider)));
}
function _initBarScrub(bar, fill, onMove, noTouch = false) {
if(!bar || !fill) return;
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) + '%';
const dur = _dur();
if (HAS_BRIDGE) {
_nsBridge('seekTo', Math.round(pos * _lastDurationMs));
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);
} else {
S._scrubbing = false;
fill.style.transition = '';
}
}
if (noTouch) {
// Block all touch input (including synthetic mousedown the browser fires after a tap).
bar.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false });
} else {
// Touch: require ≥8px horizontal drag before committing to scrub.
// A plain tap cancels cleanly without seeking.
let _touchStartX = 0, _touchMoved = false;
bar.addEventListener('touchstart', e => {
_touchStartX = e.touches[0].clientX;
_touchMoved = false;
e.stopPropagation();
e.preventDefault(); // block synthetic mousedown/click that would trigger scrub on plain tap
});
bar.addEventListener('touchmove', e => {
const dx = Math.abs(e.touches[0].clientX - _touchStartX);
if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); }
if (_touchMoved && S._scrubbing) moveScrub(getPos(e.touches[0].clientX));
}, {passive:true});
bar.addEventListener('touchend', e => {
if (_touchMoved && S._scrubbing) endScrub(getPos(e.changedTouches[0].clientX));
else S._scrubbing = false;
_touchMoved = false;
}, {passive:true});
}
bar.addEventListener('pointerdown', e => {
if (noTouch && e.pointerType === 'touch') return; // bottom bar: mouse/stylus only
startScrub(getPos(e.clientX)); e.preventDefault();
const mm = e2 => { if(S._scrubbing) moveScrub(getPos(e2.clientX)); };
const mu = e2 => { if(S._scrubbing) endScrub(getPos(e2.clientX)); document.removeEventListener('pointermove',mm); document.removeEventListener('pointerup',mu); };
document.addEventListener('pointermove', mm);
document.addEventListener('pointerup', mu);
});
}
export function setVolume(v) {
// Square curve: slider 50 → actual 0.25, slider 30 → actual 0.09
// Matches how ears perceive loudness (logarithmic) so low values feel genuinely quiet.
const vol = Math.pow(v / 100, 2);
S.audio.volume = vol;
S.previewAudio.volume = vol;
const absAudio = document.getElementById('abs-audio');
if (absAudio) absAudio.volume = vol;
document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; });
if (HAS_BRIDGE) _nsBridge('setVolume', vol);
try { localStorage.setItem('ns_volume', v); } catch (_) {}
}
// ─── MEDIA SESSION ────────────────────────────────────────────────────────
let _msHandlersSet = false;
export function updateMediaSession(s) {
// When native is active, Media3 owns the system notification — don't touch it.
if (HAS_BRIDGE) return;
const ms = navigator.mediaSession;
if(!ms) return;
const artwork = s.coverArt ? [
{ src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' },
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
] : [];
// Mutate the existing metadata in place rather than assigning a new MediaMetadata —
// replacing the object makes Windows tear down & rebuild the media tile (visible
// flicker when skipping). Spotify-style: keep one object, just update its fields.
if (ms.metadata) {
ms.metadata.title = s.title;
ms.metadata.artist = s.artist || '';
ms.metadata.album = s.album || '';
ms.metadata.artwork = artwork;
} else {
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
}
// Action handlers are stable — set them once, not on every track change.
if (_msHandlersSet) return;
_msHandlersSet = true;
ms.setActionHandler('play', togglePlay);
ms.setActionHandler('pause', togglePlay);
ms.setActionHandler('previoustrack', prevSong);
ms.setActionHandler('nexttrack', nextSong);
ms.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
ms.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
ms.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
}
export function syncMediaSessionPosition() {
if (HAS_BRIDGE) return;
if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return;
const now = Date.now();
if(now - S._msPosLast < 1000) return;
S._msPosLast = now;
try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {}
}
// ─── STAR ─────────────────────────────────────────────────────────────────
export async function toggleStar(song, btn) {
try {
await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`);
song._starred = !song._starred;
if(btn) {
btn.classList.toggle('starred', song._starred);
btn.querySelector('svg')?.setAttribute('fill', song._starred ? 'currentColor' : 'none');
}
if(S.queue[S.qIdx]?.id === song.id) updateStarBtn();
} catch(e) { toast('Fehler: ' + e.message); }
}
export function starCurrent() {
if(S.qIdx < 0 || !S.queue[S.qIdx]) return;
toggleStar(S.queue[S.qIdx], document.getElementById('btn-star-current'));
}
export function updateStarBtn() {
const s = S.queue[S.qIdx];
const icon = document.getElementById('star-icon');
if(!icon) return;
const isStarred = s && (s._starred || s.starred);
icon.setAttribute('fill', isStarred ? 'var(--accent)' : 'none');
icon.setAttribute('stroke', isStarred ? 'var(--accent)' : 'var(--text3)');
}
export function syncNpStar() {
if(!S.npOpen) return;
const s = S.queue[S.qIdx];
const icon = document.getElementById('np-star-icon');
if(!icon || !s) return;
const starred = s._starred || s.starred;
icon.setAttribute('fill', starred ? 'var(--accent)' : 'none');
icon.setAttribute('stroke', starred ? 'var(--accent)' : 'rgba(255,255,255,.5)');
}
export function npToggleStar() {
const s = S.queue[S.qIdx];
if(!s) return;
api(s._starred || s.starred ? 'unstar' : 'star', `&id=${s.id}`)
.then(() => { s._starred = !(s._starred || s.starred); s.starred = null; syncNpStar(); updateStarBtn(); })
.catch(e => toast('Fehler: ' + e.message));
}
// ─── QUEUE PANEL ──────────────────────────────────────────────────────────
let _renderedQueueKey = '';
export function renderQueue() {
const el = document.getElementById('queue-list');
if (!el) return;
const queueKey = S.queue.map(s => s.id).join(',');
if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) {
el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx));
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
return;
}
_renderedQueueKey = queueKey;
el.innerHTML = '';
if (!S.queue.length) { el.innerHTML = '<div class="empty-state" style="padding:40px 16px"><div class="icon">🎵</div><p>Leer</p></div>'; return; }
S.queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`;
item.onclick = () => { S.qIdx = i; playCurrent(); };
el.appendChild(item);
});
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
}
export function toggleQueue() {
S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
if(S.queueOpen) renderQueue();
_syncOverlayState();
}
// Called after showRomaji is toggled — re-renders all live player UI with new display mode.
export function refreshDisplayMode() {
_renderedQueueKey = ''; // force queue re-render
if (S.queueOpen) renderQueue();
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); _syncNpDirect(s); }
}
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
export function dlSongLocal(s) {
const a = document.createElement('a');
a.href = downloadUrl(s.id);
a.download = `${s.artist||'Unknown'} - ${s.title}.${s.suffix||'mp3'}`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
toast(`⬇ ${s.title}`);
}
export function downloadCurrent() {
if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]);
}
// ─── PREVIEW ──────────────────────────────────────────────────────────────
export function playPreview(track) {
if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; }
const wasPlaying = HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
if(wasPlaying) {
if (HAS_BRIDGE) _nsBridge('pause');
else S.audio.pause();
}
S.previewCurrent = track;
S.previewCurrent._resumeMain = wasPlaying;
S.previewAudio.volume = S.audio.volume;
S.previewAudio.src = track.previewUrl;
S.previewAudio.play();
document.getElementById('preview-title').innerHTML = esc(track.trackName||'—') + '<span class="preview-badge">30s Preview</span>';
document.getElementById('preview-sub').textContent = (track.artistName||'') + (track.collectionName?' · '+track.collectionName:'');
const art = track.artworkUrl100 || track.artworkUrl60 || '';
document.getElementById('preview-art').innerHTML = art ? `<img src="${art}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art" style="font-size:16px">♪</div>';
document.getElementById('preview-bar').classList.add('visible');
document.getElementById('preview-fill').style.width = '0%';
}
export function stopPreview(resume) {
const shouldResume = resume !== false && S.previewCurrent?._resumeMain;
S.previewAudio.pause(); S.previewAudio.src = '';
document.getElementById('preview-bar').classList.remove('visible');
document.getElementById('preview-fill').style.width = '0%';
S.previewCurrent = null;
if (shouldResume) {
if (HAS_BRIDGE) _nsBridge('play');
else if (S.audio.src) S.audio.play();
}
}
export function updatePreviewProgress() {
if(!S.previewAudio.duration) return;
document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%';
}
// ─── RESTORE LAST PLAYED ──────────────────────────────────────────────────
// ─── BACK-BUTTON / OVERLAY STATE SYNC ────────────────────────────────────
// Native back button: when JS has an overlay open, Android delegates the event
// to window.NSPlayer.handleBack() instead of minimising the app.
function _hasAnyOverlayOpen() {
if (S.npOpen || S.queueOpen) return true;
if (document.getElementById('np-sheet')?.classList.contains('open')) return true;
if (document.getElementById('ctx-menu')?.style.display === 'block') return true;
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) return true;
return ['settings-modal','pl-modal','info-modal','dl-modal','edit-pl-modal','confirm-modal','notif-panel']
.some(id => document.getElementById(id)?.classList.contains('open'));
}
export function _syncOverlayState() {
if (!HAS_BRIDGE) return;
try { window.NSBridge.setOverlayState(_hasAnyOverlayOpen()); } catch {}
}
window.NSPlayer.handleBack = function() {
// Close overlays from topmost layer downward.
if (document.getElementById('np-sheet')?.classList.contains('open')) {
window.closeNpMenu?.(); _syncOverlayState(); return;
}
const ctxMenu = document.getElementById('ctx-menu');
if (ctxMenu?.style.display === 'block') {
ctxMenu.style.display = 'none'; ctxMenu.innerHTML = '';
_syncOverlayState(); return;
}
if (S.npOpen) {
window.closeNowPlaying?.(); _syncOverlayState(); return;
}
if (S.queueOpen) {
toggleQueue(); _syncOverlayState(); return;
}
// Generic modals
const modalMap = {
'settings-modal': 'closeSettings',
'pl-modal': 'closePlModal',
'info-modal': 'closeInfoModal',
'dl-modal': 'closeDlModal',
'edit-pl-modal': 'closeEditPlModal',
'confirm-modal': '_confirmCancel',
};
for (const [id, fn] of Object.entries(modalMap)) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) {
if (window[fn]) window[fn](); else el.classList.remove('open');
_syncOverlayState(); return;
}
}
// Notif panel (generic fallback)
const notifPanel = document.getElementById('notif-panel');
if (notifPanel?.classList.contains('open')) {
notifPanel.classList.remove('open'); _syncOverlayState(); return;
}
// Sidebar
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) {
window.closeSidebar?.(); _syncOverlayState(); return;
}
};
// MutationObserver keeps hasOpenOverlay in sync whenever the DOM changes class
// attributes (e.g. any overlay gains/loses the 'open' class).
if (HAS_BRIDGE) {
const _startOverlayObserver = () => {
if (!document.body) return;
new MutationObserver(_syncOverlayState).observe(document.body, {
attributes: true, attributeFilter: ['class'], subtree: true,
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _startOverlayObserver);
} else {
_startOverlayObserver();
}
}
// Immediately sync JS track/play state with the native bridge snapshot.
// Called on app foreground so the UI is correct before any deferred evaluateJavascript callbacks fire.
export function reconcileNativeState() {
if (!HAS_BRIDGE) return;
try {
const snap = JSON.parse(window.NSBridge.getState() || '{}');
const snapId = String(snap.currentId || '');
if (snapId && snapId !== String(S.queue[S.qIdx]?.id || '')) {
const found = S.queue.findIndex(s => String(s.id) === snapId);
if (found >= 0) {
S.qIdx = found;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); syncNowPlaying(); onNpSongChange(s.id); renderQueue(); }
}
}
if (typeof snap.isPlaying === 'boolean' && snap.isPlaying !== _nativePlaying) {
_nativePlaying = snap.isPlaying;
setPlayIcon(snap.isPlaying);
}
} catch {}
}
export function _restoreLastPlayed() {
try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
if(!saved || !saved.song) return;
S.queue = saved.queue || [saved.song];
S.qIdx = saved.qIdx || 0;
const s = S.queue[S.qIdx];
if(!s) return;
document.getElementById('player-title').textContent = dispTitle(s) || s.title || '—';
const artistParts = dispArtistParts(s, parseArtists);
document.getElementById('player-artist-name').innerHTML = artistParts.length
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="color:var(--text3)"> • </span>')
: '—';
document.getElementById('player-album-name').textContent = dispAlbum(s);
const art = document.getElementById('player-art');
const cv = coverUrl(s.coverArt, 256);
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`;
if (HAS_BRIDGE) 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();
} catch {}
}