Lyrics: Schalter "Mitlaufen" im Lyrics-Reiter, Vorgabe AUS. Hervorgehoben wird die laufende Zeile immer, gescrollt nur bei eingeschaltetem Mitlaufen. Nur bei synchronen Texten sichtbar. Android speichert den Schluessel einzeln, damit das Speichern im Einstellungsbildschirm ihn nicht auf die Vorgabe zuruecksetzt. Mischen (Web): sort(() => Math.random() - 0.5) ist keine Mischung. Gemessen blieb der erste Titel drei- bis fuenfmal zu oft vorn (18,8 % statt 10 % bei n=10, 0,23 % statt 0,05 % bei n=2000). Ersetzt durch Fisher-Yates in queueMath.mischeListe. Queue-Ende (Web): ein beendetes <audio> meldet sich als pausiert, die Wiederaufnahme nach unfreiwilligem Hintergrund-Stop rief play() und spielte den letzten Titel erneut. Jetzt Abbruch bei audio.ended. Shuffle (Android): das Mischen macht jetzt der Player (playSongs(liste, -1)), damit die ungemischte Liste erhalten bleibt — vorher leuchtete das Symbol nicht und "Shuffle aus" konnte die Albumreihenfolge nicht wiederherstellen. Radio-Modus (beide): der laufende Titel ist aus der letzten Rueckfallebene ausgenommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1439 lines
60 KiB
JavaScript
1439 lines
60 KiB
JavaScript
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, renderNpQueue } from './nowplaying.js';
|
||
import { logPlay } from './scrobbleLog.js';
|
||
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
|
||
import { queueNachEntfernen, queueNachVerschieben, mischeListe } from './queueMath.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 = mischeListe(songs).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 = mischeListe(S.queue.filter((_, i) => i !== S.qIdx));
|
||
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));
|
||
// Ist auch der Zufall erschoepft, sind Wiederholungen erlaubt, damit Musik laeuft — aber
|
||
// NIE der gerade laufende Titel: der landete sonst direkt hinter sich selbst und spielte
|
||
// zweimal in Folge.
|
||
newSongs = randomFresh.length > 0
|
||
? randomFresh
|
||
: newSongs.filter(s => s.id !== seed?.id).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' },
|
||
] : [];
|
||
// Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das
|
||
// Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS
|
||
// aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die
|
||
// erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues
|
||
// Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird).
|
||
const wechsel = !ms.metadata
|
||
|| ms.metadata.title !== s.title
|
||
|| ms.metadata.artist !== (s.artist || '')
|
||
|| ms.metadata.album !== (s.album || '');
|
||
if (wechsel) {
|
||
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
||
} else {
|
||
ms.metadata.artwork = 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' : '');
|
||
item.dataset.qidx = i;
|
||
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>` + QUEUE_WERKZEUGE;
|
||
item.onclick = () => { S.qIdx = i; playCurrent(); };
|
||
el.appendChild(item);
|
||
});
|
||
macheQueueBearbeitbar('queue-list');
|
||
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
|
||
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
|
||
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
|
||
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
|
||
|
||
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
|
||
export function queueEntfernen(i) {
|
||
if (i < 0 || i >= S.queue.length) return;
|
||
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
|
||
S.queue.splice(i, 1);
|
||
S.qIdx = qIdx;
|
||
_renderedQueueKey = '';
|
||
|
||
if (!S.queue.length) {
|
||
S.qIdx = -1;
|
||
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
|
||
setPlayIcon(false);
|
||
renderQueue(); renderNpQueue();
|
||
toast('Warteschlange geleert');
|
||
return;
|
||
}
|
||
if (warAktuell) playCurrent();
|
||
renderQueue(); renderNpQueue();
|
||
}
|
||
|
||
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
|
||
export function queueVerschieben(von, nach) {
|
||
const n = S.queue.length;
|
||
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
|
||
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
|
||
const [titel] = S.queue.splice(von, 1);
|
||
S.queue.splice(nach, 0, titel);
|
||
S.qIdx = qIdx;
|
||
_renderedQueueKey = '';
|
||
renderQueue(); renderNpQueue();
|
||
}
|
||
|
||
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 {}
|
||
}
|