Files
NovaSonic/src/player.js
T
HoneyandClaude Sonnet 4.6 467ee8a0fe fix: consistent artist parsing across player, NP overlay, and song list
Extract parseArtists() helper — uses Navidrome's resolved artists array
when it has multiple entries, otherwise splits artist string on ';' only.
Removes old split on / that broke Au/Ra, HUNTR/X etc everywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:08:33 +02:00

1171 lines
48 KiB
JavaScript

import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.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 _skipping = false; // true briefly after a skip — suppresses the transient isPlaying:false
let _skipTimer = null;
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(id) {
if (_scrobbleId === String(id)) return; // already scheduled for this track
_cancelScrobble();
_scrobbleId = String(id);
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
_scrobbleId = null;
}, 15_000);
}
function _cancelScrobble() {
clearTimeout(_scrobbleTimer);
_scrobbleTimer = null;
_scrobbleId = null;
}
// 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);
// Push the current JS queue to native whenever S.queue changes (radio top-ups, etc.).
function _pushQueueIfBridge() {
if (!HAS_BRIDGE) return;
try {
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, !S.audio.paused);
} catch (e) { console.warn('NSBridge.setQueue failed', e); }
}
// Insert a single song into the native queue at the given index (-1 = append).
export function nativeInsertAt(song, position) {
if (!HAS_BRIDGE) return;
try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {}
}
// ─── 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() {
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 nativeIdx = typeof state.currentIndex === 'number' ? state.currentIndex : S.qIdx;
const nativeId = state.currentId || '';
// Helper: true if native's current song matches JS queue at nativeIdx
const inSync = nativeIdx >= 0 && nativeIdx < S.queue.length &&
(!nativeId || !S.queue[nativeIdx]?.id ||
String(nativeId) === String(S.queue[nativeIdx].id));
if (nativeLen > 0 && nativePlaying) {
if (inSync) {
// Native is actively playing and we have the right song — re-sync.
S.qIdx = nativeIdx;
_nativePlaying = true;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(true);
renderQueue();
} else {
// Native is playing a song we don't have (e.g. radio advanced while backgrounded).
// Reset the stale JS queue to avoid showing the wrong song — native keeps playing.
S.queue = [];
S.qIdx = 0;
}
} else if (nativeLen > 0 && inSync) {
// Paused but we can confirm the current song — re-sync UI state only.
S.qIdx = nativeIdx;
_nativePlaying = false;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(false);
renderQueue();
} else if (!nativePlaying) {
// Native is idle/empty — safe to push JS state (e.g. after cold start).
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, false);
}
} catch (e) {}
}
// Always sync shuffle/repeat settings.
_nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
_nsBridge('setShuffleMode', S.shuffle);
},
// Play/pause/buffer state changed.
onPlaybackStateChanged(stateJson) {
try {
const state = JSON.parse(stateJson);
if (_skipping) {
if (state.isPlaying) {
// isPlaying:true during skip/seek — restart grace period instead of clearing immediately,
// so a follow-up buffering false event is also suppressed.
clearTimeout(_skipTimer);
_skipTimer = setTimeout(() => { _skipping = false; }, 600);
} else {
// Suppress transient isPlaying:false during track transitions / seeks.
return;
}
}
const prev = _nativePlaying;
_nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
if (state.isPlaying && S.previewCurrent) stopPreview(false);
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';
// Safety net: when playback starts, verify JS is showing the correct track.
// Catches any edge cases where onTrackChanged was missed or queue drifted.
if (state.isPlaying) {
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
const liveIdx = live.currentIndex;
const liveId = live.currentId || '';
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
liveIdx >= 0 && liveIdx < S.queue.length) {
const expected = S.queue[liveIdx];
// Only update if song ID confirms native and JS queues are in sync.
if (!liveId || !expected?.id || String(liveId) === String(expected.id)) {
S.qIdx = liveIdx;
if (expected) { updatePlayerUI(expected, true); onNpSongChange(expected.id); }
}
}
} catch {}
}
}
} catch (e) {}
},
// Native moved to a different track (auto-advance, seek, or queue replace).
onTrackChanged(payloadJson) {
try {
const p = JSON.parse(payloadJson);
if (typeof p.index !== 'number') return;
// Reconcile with the live native state so stale out-of-order callbacks
// (e.g. from rapid skips) don't overwrite the already-correct index.
let idx = p.index;
let liveId = '';
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
if (typeof live.currentIndex === 'number') idx = live.currentIndex;
liveId = live.currentId || '';
} catch {}
if (idx < 0 || idx >= S.queue.length) return;
// Reject stale callbacks from the old queue: if the song ID at this index
// doesn't match what native is actually playing, the queues are mismatched.
// Allow through if reason='queue' (we triggered a setQueue) or if no IDs to compare.
if (liveId && S.queue[idx]?.id && String(liveId) !== String(S.queue[idx].id) && p.reason !== 'queue') return;
const indexChanged = (idx !== S.qIdx);
S.qIdx = idx;
const s = S.queue[S.qIdx];
if (!s) return;
updatePlayerUI(s, true);
onNpSongChange(s.id);
if (indexChanged) renderQueue();
// Schedule scrobble — fires after 12s of uninterrupted playback.
_scheduleScrobble(s.id);
// Radio mode: top up the queue when running low.
// fillRadioQueue sends addToQueue to native internally — no re-send here.
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;
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(() => {
if (S.queue.length > prevLen) { _nsBridge('play'); return; }
// fillRadioQueue was locked by a concurrent fill — wait for it to finish
// then trigger play so native doesn't stay stopped after queue end.
if (_radioFilling) setTimeout(() => { if (S.queue.length > prevLen) _nsBridge('play'); }, 3000);
});
}
},
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 (resource hint keeps connection open through Doze gap) ───────
let _preUrl = '';
let _preId = '';
let _preLink = 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; // 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);
if(_preLink) _preLink.remove();
_preLink = document.createElement('link');
_preLink.rel = 'prefetch';
_preLink.as = 'fetch';
_preLink.href = _preUrl;
document.head.appendChild(_preLink);
if(next.coverArt) {
[256, 512, 1024].forEach(size => {
const img = new Image();
img.src = _cachedCoverUrl(next.coverArt, size);
});
}
}
function _clearPreload() {
_preId = ''; _preUrl = '';
if(_preLink) { _preLink.remove(); _preLink = null; }
}
// ─── 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) {
_queueGen++;
_radioFilling = false; // cancel any in-flight radio fill for the old queue
S._origQueue = null;
if (!HAS_BRIDGE && S.shuffle && songs.length > 1) {
const start = songs[startIdx];
const rest = songs.filter((_, i) => i !== startIdx).sort(() => Math.random() - 0.5);
S._origQueue = songs.map(s => ({...s}));
S.queue = [start, ...rest].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();
// Build the track list now (before rAF) while S.queue is current.
const trackListJson = JSON.stringify(buildTrackList());
// Defer the NSBridge call to AFTER the current render frame so the
// updatePlayerUI DOM changes are committed and visible before the
// @JavascriptInterface call (which blocks the JS thread while Java
// parses the JSON and creates MediaItems).
requestAnimationFrame(() => {
try { window.NSBridge.setQueue(trackListJson, startIdx, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
// fillRadioQueue AFTER setQueue so addToQueue lands in the new native queue,
// not the old one that setQueue is about to wipe.
if (S.radioMode) fillRadioQueue();
});
return;
}
playCurrent();
renderQueue();
if(S.radioMode) fillRadioQueue();
}
export function playCurrent() {
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
const s = S.queue[S.qIdx];
if (HAS_BRIDGE) {
// Update DOM immediately for snappy feedback; onTrackChanged will confirm.
S._trackChanging = true;
_resetProgressUI();
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
return;
}
const gen = ++_playGen;
S._trackChanging = true;
_resetProgressUI();
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_scheduleScrobble(s.id); // 12s timer — cancelled on pause/skip
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
const _applySession = () => {
if (_playGen !== gen) return;
updateMediaSession(s);
setTimeout(_preloadNext, 2000);
};
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 = s.title;
const artistParts = parseArtists(s);
document.getElementById('player-artist-name').innerHTML = artistParts.length
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
.join('<span style="color:var(--text3)"> • </span>')
: '—';
document.getElementById('player-album-name').textContent = s.album || '';
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();
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 },
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 })),
qIdx: S.qIdx,
}));
} catch {}
_syncNpDirect(s);
_updatePlayingHighlights();
// Preload the hi-res NP cover now so opening the overlay is instant
const _npCv = coverUrl(s.coverArt, 1024);
if (_npCv && _npCv !== _npBgLastUrl) { const _pre = new Image(); _pre.src = _npCv; }
}
let _npBgCurr = 'a';
let _npBgLastUrl = '';
function _crossfadeNpBg(cv) {
if ((cv || '') === _npBgLastUrl) return;
_npBgLastUrl = 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 = s.title || '—';
const an = document.getElementById('np-artist-name');
if(an) {
const parts = parseArtists(s);
an.innerHTML = parts.length
? parts.map(a => `<span style="cursor:pointer" onclick="closeNowPlaying();s_goArtist(event,'${esc(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 = s.album || '';
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.audio.play(); }
else { 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;
if (S.shuffle && S.queue.length > 1) {
let n = S.qIdx;
while (n === S.qIdx) n = Math.floor(Math.random() * S.queue.length);
return n;
}
if (S.qIdx + 1 < S.queue.length) return S.qIdx + 1;
if (S.repeat === 'all') return 0;
return -1;
}
function _setSkipping() {
_skipping = true;
clearTimeout(_skipTimer);
_skipTimer = setTimeout(() => { _skipping = false; }, 2000);
}
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) {
_setSkipping();
if (_lastPositionMs > 3000) {
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
_resetProgressUI();
_nsBridge('seekTo', 0);
return;
}
if (S.qIdx > 0) {
S.qIdx--;
const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); }
// Explicit index so native doesn't have to agree on what "previous" means.
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
}
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) {
_setSkipping();
const ni = _computeNextIdx();
if (ni >= 0 && ni < S.queue.length) {
S.qIdx = ni;
const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); }
// Explicit index prevents desync when JS and native queue pointers diverge.
_nsBridge('seekToIndex', ni, 0);
_nsBridge('play');
} else {
// Shuffle mode: native picks the shuffled-next item; onTrackChanged will confirm the index.
_nsBridge('next');
}
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; }
_cancelScrobble(); // timer irrelevant now — song already finished
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 (!HAS_BRIDGE && 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();
}
_applyShuffleRepeat();
if (HAS_BRIDGE) _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 (HAS_BRIDGE) _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
}
// ─── 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;
const prevLen = S.queue.length;
S.queue.push(...newSongs);
renderQueue();
// Append-only: never call setQueue here (that resets position to 0).
S.queue.slice(prevLen).forEach(s => _nsBridge('addToQueue', JSON.stringify(_trackToNative(s)), -1));
} 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 < 30 && !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) {
_setSkipping();
_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) {
_setSkipping();
_nsBridge('seekTo', Math.round(ms));
} else {
S.audio.currentTime = ms / 1000;
}
}
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) {
_setSkipping();
_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 ────────────────────────────────────────────────────────
export function updateMediaSession(s) {
// When native is active, Media3 owns the system notification — don't touch it.
if (HAS_BRIDGE) return;
if(!navigator.mediaSession) 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' },
] : [];
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('previoustrack', prevSong);
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; });
}
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(s.title)}</div><div class="qi-artist">${esc(s.artist||'')}</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();
}
// ─── 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();
}
}
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 = s.title || '—';
const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean);
document.getElementById('player-artist-name').innerHTML = artistParts.length
? artistParts.map((name, i) => {
const aid = i === 0 ? (s.artistId || '') : '';
return `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(name)}','${aid}')">${esc(name)}</span>`;
}).join('<span style="color:var(--text3)"> • </span>')
: '—';
document.getElementById('player-album-name').textContent = s.album || '';
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 {}
}