import { S } from '../state.js'; import { esc, formatDur, toast } from '../utils.js'; import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js'; // views.js/ui.js circular imports — safe (only used inside function bodies) import { loadRecommendations } from '../views/views.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; import { logPlay } from './scrobbleLog.js'; // ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── // True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl). const HAS_BRIDGE = typeof window.NSBridge !== 'undefined'; // Track native playback state; driven by window.NSPlayer callbacks below. let _nativePos = 0; // ms let _nativePlaying = false; let _lastPositionMs = 0; // ms — set by onPositionUpdate, used for prev() decision let _lastDurationMs = 0; // ms — set by onPositionUpdate, used for seek calculations let _readyTs = 0; // timestamp of last onReady — used to suppress spurious onQueueEnded let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ignored before this // ─── SCROBBLE ───────────────────────────────────────────────────────────── let _scrobbleTimer = null; let _scrobbleId = null; function _scheduleScrobble(s) { const id = s?.id; if (!id || _scrobbleId === String(id)) return; _cancelScrobble(); _scrobbleId = String(id); const mode = S.cfg.scrobbleMode || '15s'; if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded let delay; const dur = (s?.duration || 0) * 1000; switch (mode) { case '30s': delay = 30_000; break; case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break; case '2/3': delay = dur > 0 ? Math.floor(dur * 2 / 3) : 60_000; break; default: delay = 15_000; break; } _scrobbleTimer = setTimeout(() => { if (_scrobbleId) { api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {}); logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment } _scrobbleId = null; }, delay); } function _cancelScrobble() { clearTimeout(_scrobbleTimer); _scrobbleTimer = null; _scrobbleId = null; } function _scrobbleNow(s) { if (s) { api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {}); logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment } } // "Now Playing" ping (submission=false) — tells the server what's playing *right now* so // getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct // from the delayed "played" scrobble above, which counts the play for stats / Last.fm. function _scrobbleNowPlaying(s) { if (s) api('scrobble', `&id=${s.id}&submission=false`).catch(() => {}); } // Unified duration / position / seek helpers — use native state when available, // fall back to HTMLAudioElement when not. const _dur = () => HAS_BRIDGE ? _lastDurationMs / 1000 : (S.audio.duration || 0); const _pos = () => HAS_BRIDGE ? _nativePos / 1000 : (S.audio.currentTime || 0); const _seek = ms => HAS_BRIDGE ? _nsBridge('seekTo', ms) : (S.audio.currentTime = ms / 1000); // Safe NSBridge caller — never throws into JS. function _nsBridge(method, ...args) { try { window.NSBridge[method](...args); } catch(_) {} } // Convert a single Navidrome song to the NSBridge Track schema. function _trackToNative(s) { return { id: s.id, url: streamUrl(s.id), title: s.title || '', artist: s.artist || '', album: s.album || null, artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null, durationMs: Math.floor((s.duration || 0) * 1000), }; } // Build the NSBridge Track JSON array from S.queue. const buildTrackList = () => S.queue.map(_trackToNative); // Single-track JSON for setQueue — ExoPlayer always holds exactly one item. const _nativeSingle = s => JSON.stringify([_trackToNative(s)]); // Persist the current track to Android SharedPreferences so MusicService can // pre-warm ExoPlayer on the next cold start before the WebView finishes loading. function _persistLastPlayed(s) { if (!HAS_BRIDGE || !s) return; try { window.NSBridge.saveLastPlayed(JSON.stringify(_trackToNative(s))); } catch (_) {} } // Reload native player with just the current song. The single-track model means // ExoPlayer is never responsible for queue order — JS owns that entirely. function _pushQueueIfBridge() { if (!HAS_BRIDGE) return; const s = S.queue[S.qIdx]; if (!s) return; try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); } catch (e) { console.warn('NSBridge.setQueue failed', e); } } // Radio top-ups: add to S.queue only. ExoPlayer gets the new song when JS naturally // picks it via nextSong() → setQueue([single], 0, true). No native queue manipulation. export function nativeInsertAt(_song, _position) { /* no-op in single-track model */ } // ─── NATIVE → JS CALLBACKS ──────────────────────────────────────────────── // window.NSPlayer is the surface that MusicService calls back into. // Installed here so it exists before onPageFinished fires. window.NSPlayer = { // Called when MediaController connects (first load or WebView reload). onReady() { // Mark bridge as ready: onQueueEnded fired before this point came from a previous // session (song finished while backgrounded) and must not advance the queue. _bridgeReady = true; _readyTs = Date.now(); if (HAS_BRIDGE && S.queue.length > 0) { try { const raw = window.NSBridge.getState(); const state = raw ? (JSON.parse(raw) || {}) : {}; const nativeLen = state.queueLength || 0; const nativePlaying = state.isPlaying || false; const nativeEnded = state.isEnded || false; const nativeId = state.currentId || ''; // Single-track model: native index is always 0, meaningless for queue position. // Match by song ID against S.queue[S.qIdx] — the JS-side current position. const currentSong = S.queue[S.qIdx]; const idMatch = !nativeId || !currentSong?.id || String(nativeId) === String(currentSong.id); if (nativeLen > 0 && nativePlaying) { if (idMatch) { // Native is playing our current song — re-sync UI without touching S.qIdx. _nativePlaying = true; _nativePos = state.positionMs || 0; if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } setPlayIcon(true); renderQueue(); } else { // Native advanced to a different song while backgrounded — find it in the queue. const found = nativeId ? S.queue.findIndex(s => String(s.id) === String(nativeId)) : -1; if (found >= 0) { S.qIdx = found; _nativePlaying = true; _nativePos = state.positionMs || 0; const s = S.queue[S.qIdx]; if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); } setPlayIcon(true); renderQueue(); } else { // Playing a song not in our queue (radio advanced past known tracks) — clear. S.queue = []; S.qIdx = 0; } } } else if (nativeLen > 0 && nativeEnded && idMatch) { // Song finished while backgrounded and ExoPlayer is in STATE_ENDED. // Reset to position 0 so the user can press play to replay — do NOT advance queue. _nativePlaying = false; _nativePos = 0; if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } setPlayIcon(false); renderQueue(); _nsBridge('seekTo', 0); } else if (nativeLen > 0 && !nativePlaying && idMatch) { // Paused, song matches — re-sync UI state only. _nativePlaying = false; _nativePos = state.positionMs || 0; if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } setPlayIcon(false); renderQueue(); } else { // Native is idle/empty or has a mismatched paused song — push current (single-track model). const s = S.queue[S.qIdx]; if (s) window.NSBridge.setQueue(_nativeSingle(s), 0, false); } } catch (e) {} } // Always sync shuffle/repeat settings. // setShuffleMode is always false — we pre-shuffle the JS array ourselves and // traverse sequentially, so ExoPlayer's internal shuffle must stay off. _nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off'); // 'all' handled in JS _nsBridge('setShuffleMode', false); }, // Play/pause/buffer state changed. onPlaybackStateChanged(stateJson) { try { const state = JSON.parse(stateJson); const prev = _nativePlaying; _nativePlaying = state.isPlaying; if (!state.isPlaying && !state.isBuffering) _cancelScrobble(); if (state.isPlaying && S.previewCurrent) stopPreview(false); // Show pause icon while buffering (isPlaying=false but isBuffering=true) so the // button doesn't flash "play" during the brief re-buffer between tracks. const showPlaying = state.isPlaying || state.isBuffering; if (state.isPlaying !== prev) { setPlayIcon(showPlaying); document.getElementById('player-art')?.classList.toggle('spinning', showPlaying); if (navigator.mediaSession) navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused'; } } catch (e) {} }, // Native started playing a track (confirmation of our setQueue call). // With the single-track model, JS already updated S.qIdx before calling setQueue, // so nothing needs to change here — just trigger scrobble and radio top-up. onTrackChanged(payloadJson) { try { const s = S.queue[S.qIdx]; if (!s) return; _scheduleScrobble(s); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); } catch (e) {} }, // ~250 ms position tick while playing. onPositionUpdate(posMs, durMs) { _nativePos = posMs; _lastPositionMs = posMs; _lastDurationMs = durMs; if (S._scrubbing) return; // Single-track model: no queue drift possible, reconciliation tick removed. const dur = durMs / 1000; const cur = posMs / 1000; if (dur <= 0) return; const p = (cur / dur) * 100; document.getElementById('progress-fill').style.width = p + '%'; document.getElementById('time-cur').textContent = formatDur(cur); document.getElementById('time-total').textContent = formatDur(dur); if (S.npOpen) { const fill = document.getElementById('np-fill'); const curEl = document.getElementById('np-cur'); const totEl = document.getElementById('np-tot'); if (fill) fill.style.width = p + '%'; if (curEl) curEl.textContent = formatDur(cur); if (totEl) totEl.textContent = formatDur(dur); if (S.npLyricsMode) updateLyricsHighlight(posMs); } }, // Single track finished — JS drives what plays next. onQueueEnded() { // Ignore if onReady hasn't fired yet: the song ended while the app was backgrounded // and the callback arrived before the new page session is fully connected. // Also ignore the brief window right after onReady (stale STATE_ENDED replay). if (!_bridgeReady || Date.now() - _readyTs < 1500) return; _nativePlaying = false; setPlayIcon(false); if (S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]); onEnded(); // handles repeat, radio top-up, and queue advance }, // Notification next/prev buttons: intercept at MediaSession level so JS controls queue. onNotificationNext() { nextSong(); }, onNotificationPrev() { prevSong(); }, onError(message) { toast('Playback error: ' + message); }, }; // ─── COVER ART URL CACHE ─────────────────────────────────────────────────── // Stable URLs let the OS cache artwork images — without this every skip regenerates a new // auth token URL, Samsung has to re-fetch the image, and the lock screen notification // visually reloads. const _coverCache = new Map(); function _cachedCoverUrl(id, size) { const key = `${id}:${size}`; if (!_coverCache.has(key)) _coverCache.set(key, coverUrl(id, size)); return _coverCache.get(key); } // ─── AUDIO SESSION — set once, never again ─────────────────────────────── let _audioSessionSet = false; function _setAudioSession() { if (HAS_BRIDGE || _audioSessionSet) return; _audioSessionSet = true; try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} } // ─── PRELOAD — the standby audio element buffers the next track ────────────── // Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to // it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the // new artwork is ready too. Falls back to a source-swap when the next track isn't preloaded. let _preUrl = ''; let _preId = ''; /** The audio element that is NOT currently active (or null if there's only one). */ function _standbyEl() { const els = S._audioEls; if (!els || els.length < 2) return null; return els.find(e => e !== S.audio) || null; } function _nextIdx() { if(S.repeat === 'one') return S.qIdx; if(S.qIdx + 1 < S.queue.length) return S.qIdx + 1; if(S.repeat === 'all') return 0; return -1; } function _preloadNext() { if (HAS_BRIDGE) return; const ni = _nextIdx(); if(ni < 0) return; const next = S.queue[ni]; if(!next || _preId === next.id) return; const standby = _standbyEl(); if(!standby) return; _preId = next.id; _preUrl = streamUrl(next.id); standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly try { standby.load(); } catch {} if(next.coverArt) { [256, 512, 1024].forEach(size => { const img = new Image(); img.src = _cachedCoverUrl(next.coverArt, size); }); } } function _clearPreload() { _preId = ''; _preUrl = ''; const standby = _standbyEl(); if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} } } // ─── PLAY HISTORY (for prev-in-shuffle) ────────────────────────────────────── // Records the qIdx before each forward advance so prevSong() can retrace the // actual play order instead of blindly decrementing (which is wrong in shuffle). const _playHist = []; function _histPush(idx) { if (_playHist[_playHist.length - 1] === idx) return; _playHist.push(idx); if (_playHist.length > 50) _playHist.shift(); } function _histClear() { _playHist.length = 0; } // ─── PLAYBACK ───────────────────────────────────────────────────────────── let _playGen = 0; // Incremented on every playQueue() call so in-flight fillRadioQueue callbacks // from the previous queue can detect they're stale and bail out. let _queueGen = 0; export function playQueue(songs, startIdx, opts = {}) { _queueGen++; _radioFilling = false; // cancel any in-flight radio fill for the old queue S._origQueue = null; _histClear(); // Shuffle: fully random order, no pinning — startIdx is ignored. // Both web and Android traverse this fixed shuffled order sequentially. // opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page). if (S.shuffle && songs.length > 1 && !opts.noShuffle) { S._origQueue = songs.map(s => ({...s})); S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s})); S.qIdx = 0; } else { S.queue = songs.map(s => ({...s})); S.qIdx = startIdx; } _clearPreload(); if (HAS_BRIDGE) { const s = S.queue[S.qIdx]; if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); } renderQueue(); // Single-track model: ExoPlayer always holds exactly one song. // JS owns all queue logic — no desync possible. const single = s ? _nativeSingle(s) : null; requestAnimationFrame(() => { if (single) { try { window.NSBridge.setQueue(single, 0, true); } catch (e) { console.warn('NSBridge.setQueue failed', e); } } if (S.radioMode) fillRadioQueue(); }); return; } playCurrent(); renderQueue(); if(S.radioMode) fillRadioQueue(); } export function playCurrent() { if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; 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 => `${esc(a.name)}`) .join(' • ') : '—'; 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 ? `` : '
♪
'; 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 = ``; } // 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 ? `` : '
♪
'; } _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 => `${esc(a.name)}`) .join(' • ') : '—'; } 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.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 ? '' : ''; if(S.npOpen) { document.getElementById('np-play-icon').innerHTML = playing ? '' : ''; const cover = document.getElementById('np-cover'); if(cover) cover.classList.toggle('playing', playing); } } function _computeNextIdx() { if (S.repeat === 'one') return S.qIdx; // No special shuffle case — queue is already in shuffled order when S.shuffle is on. if (S.qIdx + 1 < S.queue.length) return S.qIdx + 1; if (S.repeat === 'all') return 0; return -1; } function _resetProgressUI() { if (HAS_BRIDGE) _nativePos = 0; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('time-cur').textContent = formatDur(0); if (S.npOpen) { const fill = document.getElementById('np-fill'); const cur = document.getElementById('np-cur'); if (fill) fill.style.width = '0%'; if (cur) cur.textContent = formatDur(0); } } export function prevSong() { _clearPreload(); if (HAS_BRIDGE) { if (_lastPositionMs > 3000) { // Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic. _resetProgressUI(); _nsBridge('seekTo', 0); return; } // Pop history to retrace actual play order (correct in shuffle where next jumps randomly). // Fall back to S.qIdx-1 when history is empty (e.g. at the very start of a queue). const prevIdx = _playHist.length > 0 ? _playHist.pop() : (S.qIdx > 0 ? S.qIdx - 1 : -1); if (prevIdx >= 0 && prevIdx < S.queue.length) { S.qIdx = prevIdx; const s = S.queue[S.qIdx]; if (s) { _resetProgressUI(); updatePlayerUI(s, true); syncNowPlaying(); onNpSongChange(s.id); renderQueue(); try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {} } } return; } if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; } if(S.qIdx > 0) { S.qIdx--; playCurrent(); } } export function nextSong() { _clearPreload(); if (HAS_BRIDGE) { const ni = _computeNextIdx(); if (ni >= 0 && ni < S.queue.length) { _histPush(S.qIdx); S.qIdx = ni; const s = S.queue[S.qIdx]; if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); // Single-track: replace native player with the new song. No seekToIndex needed. try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {} } } else { // End of queue, no repeat — stop. _nativePlaying = false; setPlayIcon(false); } if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); return; } if(S.qIdx < S.queue.length - 1) S.qIdx++; else if(S.repeat) S.qIdx = 0; else if(S.radioMode) { fillRadioQueue().then(() => { if(S.qIdx < S.queue.length-1) { S.qIdx++; playCurrent(); } }); return; } else return; if(S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); playCurrent(); } export function onEnded() { if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; } if (!HAS_BRIDGE && S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]); _cancelScrobble(); nextSong(); } export function _applyShuffleRepeat() { document.getElementById('btn-shuffle')?.classList.toggle('active', S.shuffle); document.getElementById('np-shuffle-btn')?.classList.toggle('active', S.shuffle); document.getElementById('badge-shuffle')?.classList.toggle('on', S.shuffle); document.getElementById('btn-repeat')?.classList.toggle('active', S.repeat !== false); document.getElementById('btn-repeat')?.classList.toggle('repeat-one', S.repeat === 'one'); const nb = document.getElementById('np-repeat-btn'); if(nb) { nb.classList.toggle('active', S.repeat !== false); nb.classList.toggle('repeat-one', S.repeat === 'one'); } const badge = document.getElementById('badge-repeat'); if(badge) { badge.classList.toggle('on', S.repeat !== false); badge.textContent = S.repeat === 'one' ? '🔂 Einmal' : '🔁 Repeat'; } } export function toggleShuffle() { S.shuffle = !S.shuffle; localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0'); if (S.queue.length > 1) { if (S.shuffle) { const cur = S.queue[S.qIdx]; S._origQueue = [...S.queue]; const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5); S.queue = [cur, ...rest]; S.qIdx = 0; } else if (S._origQueue) { const cur = S.queue[S.qIdx]; S.queue = S._origQueue; S._origQueue = null; const idx = S.queue.findIndex(s => s.id === cur?.id); S.qIdx = idx >= 0 ? idx : 0; } renderQueue(); // Re-send the reordered queue to native so ExoPlayer's order matches JS. if (HAS_BRIDGE) { const s = S.queue[S.qIdx]; if (s) try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); } catch(e) { console.warn('NSBridge.setQueue failed', e); } } } _applyShuffleRepeat(); } export function toggleRepeat() { S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false; localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat); _applyShuffleRepeat(); // Single-track model: native repeat is only used for 'one'; 'all' is handled in JS. if (HAS_BRIDGE) _nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off'); } // ─── RADIO ──────────────────────────────────────────────────────────────── export function _applyRadioBtn() { const btn = document.getElementById('btn-radio-mode'); if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; } const nb = document.getElementById('np-radio-btn'); if(nb) { nb.classList.toggle('active', S.radioMode); nb.style.color = S.radioMode ? 'var(--blue)' : ''; } } export async function toggleRadioMode() { S.radioMode = !S.radioMode; localStorage.setItem('ns_radio', S.radioMode ? '1' : '0'); _applyRadioBtn(); if(S.radioMode) await fillRadioQueue(); } let _radioFilling = false; export async function fillRadioQueue() { if (!S.radioMode || _radioFilling) return; _radioFilling = true; const gen = _queueGen; try { const seed = S.queue[S.qIdx]; let newSongs = []; // 1. Try similar songs seeded from the current track if (seed?.id) { const r = await api('getSimilarSongs2', `&id=${seed.id}&count=20`); newSongs = r.similarSongs2?.song || []; } if (gen !== _queueGen) return; // 2. Fall back to random if similar songs unavailable or empty if (!newSongs.length) { const r = await api('getRandomSongs', '&size=20'); newSongs = r.randomSongs?.song || []; } if (gen !== _queueGen) return; if (!newSongs.length) return; const recentIds = new Set(S.queue.slice(-50).map(s => s.id)); const deduped = newSongs.filter(s => !recentIds.has(s.id)); if (deduped.length === 0) { // All similar songs recently played (small library) — try random as final fallback if (gen !== _queueGen) return; const rr = await api('getRandomSongs', '&size=20'); const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id)); // If random is also exhausted, allow repeats to keep music playing newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5); } else { newSongs = deduped; } if (gen !== _queueGen) return; S.queue.push(...newSongs); renderQueue(); // Single-track model: songs land in S.queue; JS picks them up via nextSong(). No native push needed. } catch { } finally { _radioFilling = false; } } // ─── PROGRESS & SCRUBBING ───────────────────────────────────────────────── export function updateProgress() { if(HAS_BRIDGE) return; // driven by NSPlayer.onPositionUpdate instead if(!S.audio.duration || S._scrubbing) return; syncMediaSessionPosition(); const rem = S.audio.duration - S.audio.currentTime; if(rem > 0 && rem < 60 && !S._trackChanging) _preloadNext(); const p = (S.audio.currentTime / S.audio.duration) * 100; document.getElementById('progress-fill').style.width = p + '%'; document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime); document.getElementById('time-total').textContent = formatDur(S.audio.duration); if(S.npOpen) { const fill = document.getElementById('np-fill'); const cur = document.getElementById('np-cur'); const tot = document.getElementById('np-tot'); if(fill) fill.style.width = p + '%'; if(cur) cur.textContent = formatDur(S.audio.currentTime); if(tot) tot.textContent = formatDur(S.audio.duration); if(S.npLyricsMode) updateLyricsHighlight(S.audio.currentTime * 1000); } } export function seekTo(e) { const bar = document.getElementById('progress-bar'); const dur = _dur(); if (!bar || !dur) return; const rect = bar.getBoundingClientRect(); const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const fill = document.getElementById('progress-fill'); if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; } S._scrubbing = true; if (HAS_BRIDGE) { _nsBridge('seekTo', Math.round(pos * _lastDurationMs)); const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; setTimeout(done, 500); } else { S.audio.currentTime = pos * dur; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; S.audio.addEventListener('seeked', done, { once: true }); setTimeout(done, 1500); } } export function initProgressScrub() { // Bottom mini-bar: mouse-only (touch disabled — too easy to accidentally seek on mobile). _initBarScrub( document.getElementById('progress-bar'), document.getElementById('progress-fill'), pos => { document.getElementById('time-cur').textContent = formatDur(pos * _dur()); }, /* noTouch= */ true ); _initBarScrub( document.getElementById('np-bar'), document.getElementById('np-fill'), pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); } ); } // Export for lyrics / other callers that need to seek by ms. export function seekToMs(ms) { if (HAS_BRIDGE) { _nsBridge('seekTo', Math.round(ms)); } else { S.audio.currentTime = ms / 1000; } } // ─── DESKTOP KEYBOARD SHORTCUTS ─── // Space = play/pause · ←/→ = seek 5s · Shift+←/→ = prev/next · ↑/↓ = volume. // Skipped while typing in an input so it never hijacks the search/playlist fields. export function initKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.altKey || e.metaKey) return; // leave OS/browser combos alone const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; switch (e.code) { case 'Space': // Don't hijack Space when a button/link is focused — it would double-fire. if (t && (t.tagName === 'BUTTON' || t.tagName === 'A' || t.closest?.('button, a, [role="button"]'))) return; e.preventDefault(); togglePlay(); break; case 'ArrowRight': e.preventDefault(); if (e.shiftKey) nextSong(); else _seekBy(5); break; case 'ArrowLeft': e.preventDefault(); if (e.shiftKey) prevSong(); else _seekBy(-5); break; case 'ArrowUp': e.preventDefault(); _bumpVolume(5); break; case 'ArrowDown': e.preventDefault(); _bumpVolume(-5); break; } }); } function _seekBy(deltaSec) { const dur = _dur(); if (!dur) return; seekToMs(Math.max(0, Math.min(dur, _pos() + deltaSec)) * 1000); } function _bumpVolume(deltaSlider) { // setVolume takes a 0–100 slider value (squared internally); recover it from the actual volume. const cur = Math.round(Math.sqrt(Math.max(0, S.audio.volume || 0)) * 100); setVolume(Math.max(0, Math.min(100, cur + deltaSlider))); } function _initBarScrub(bar, fill, onMove, noTouch = false) { if(!bar || !fill) return; function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); } function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); } function moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); } function endScrub(pos) { fill.style.width = (pos * 100) + '%'; const dur = _dur(); if (HAS_BRIDGE) { _nsBridge('seekTo', Math.round(pos * _lastDurationMs)); S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); } else if (dur) { S.audio.currentTime = pos * dur; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); }; S.audio.addEventListener('seeked', done, { once: true }); setTimeout(done, 1500); } else { S._scrubbing = false; fill.style.transition = ''; } } if (noTouch) { // Block all touch input (including synthetic mousedown the browser fires after a tap). bar.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false }); } else { // Touch: require ≥8px horizontal drag before committing to scrub. // A plain tap cancels cleanly without seeking. let _touchStartX = 0, _touchMoved = false; bar.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; _touchMoved = false; e.stopPropagation(); e.preventDefault(); // block synthetic mousedown/click that would trigger scrub on plain tap }); bar.addEventListener('touchmove', e => { const dx = Math.abs(e.touches[0].clientX - _touchStartX); if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); } if (_touchMoved && S._scrubbing) moveScrub(getPos(e.touches[0].clientX)); }, {passive:true}); bar.addEventListener('touchend', e => { if (_touchMoved && S._scrubbing) endScrub(getPos(e.changedTouches[0].clientX)); else S._scrubbing = false; _touchMoved = false; }, {passive:true}); } bar.addEventListener('pointerdown', e => { if (noTouch && e.pointerType === 'touch') return; // bottom bar: mouse/stylus only startScrub(getPos(e.clientX)); e.preventDefault(); const mm = e2 => { if(S._scrubbing) moveScrub(getPos(e2.clientX)); }; const mu = e2 => { if(S._scrubbing) endScrub(getPos(e2.clientX)); document.removeEventListener('pointermove',mm); document.removeEventListener('pointerup',mu); }; document.addEventListener('pointermove', mm); document.addEventListener('pointerup', mu); }); } export function setVolume(v) { // Square curve: slider 50 → actual 0.25, slider 30 → actual 0.09 // Matches how ears perceive loudness (logarithmic) so low values feel genuinely quiet. const vol = Math.pow(v / 100, 2); S.audio.volume = vol; S.previewAudio.volume = vol; const absAudio = document.getElementById('abs-audio'); if (absAudio) absAudio.volume = vol; document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; }); if (HAS_BRIDGE) _nsBridge('setVolume', vol); try { localStorage.setItem('ns_volume', v); } catch (_) {} } // ─── MEDIA SESSION ──────────────────────────────────────────────────────── let _msHandlersSet = false; export function updateMediaSession(s) { // When native is active, Media3 owns the system notification — don't touch it. if (HAS_BRIDGE) return; const ms = navigator.mediaSession; if(!ms) return; const artwork = s.coverArt ? [ { src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' }, ] : []; // Mutate the existing metadata in place rather than assigning a new MediaMetadata — // replacing the object makes Windows tear down & rebuild the media tile (visible // flicker when skipping). Spotify-style: keep one object, just update its fields. if (ms.metadata) { ms.metadata.title = s.title; ms.metadata.artist = s.artist || ''; ms.metadata.album = s.album || ''; ms.metadata.artwork = artwork; } else { ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); } // Action handlers are stable — set them once, not on every track change. if (_msHandlersSet) return; _msHandlersSet = true; ms.setActionHandler('play', togglePlay); ms.setActionHandler('pause', togglePlay); ms.setActionHandler('previoustrack', prevSong); ms.setActionHandler('nexttrack', nextSong); ms.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); ms.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); ms.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); } export function syncMediaSessionPosition() { if (HAS_BRIDGE) return; if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return; const now = Date.now(); if(now - S._msPosLast < 1000) return; S._msPosLast = now; try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {} } // ─── STAR ───────────────────────────────────────────────────────────────── export async function toggleStar(song, btn) { try { await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`); song._starred = !song._starred; if(btn) { btn.classList.toggle('starred', song._starred); btn.querySelector('svg')?.setAttribute('fill', song._starred ? 'currentColor' : 'none'); } if(S.queue[S.qIdx]?.id === song.id) updateStarBtn(); } catch(e) { toast('Fehler: ' + e.message); } } export function starCurrent() { if(S.qIdx < 0 || !S.queue[S.qIdx]) return; toggleStar(S.queue[S.qIdx], document.getElementById('btn-star-current')); } export function updateStarBtn() { const s = S.queue[S.qIdx]; const icon = document.getElementById('star-icon'); if(!icon) return; const isStarred = s && (s._starred || s.starred); icon.setAttribute('fill', isStarred ? 'var(--accent)' : 'none'); icon.setAttribute('stroke', isStarred ? 'var(--accent)' : 'var(--text3)'); } export function syncNpStar() { if(!S.npOpen) return; const s = S.queue[S.qIdx]; const icon = document.getElementById('np-star-icon'); if(!icon || !s) return; const starred = s._starred || s.starred; icon.setAttribute('fill', starred ? 'var(--accent)' : 'none'); icon.setAttribute('stroke', starred ? 'var(--accent)' : 'rgba(255,255,255,.5)'); } export function npToggleStar() { const s = S.queue[S.qIdx]; if(!s) return; api(s._starred || s.starred ? 'unstar' : 'star', `&id=${s.id}`) .then(() => { s._starred = !(s._starred || s.starred); s.starred = null; syncNpStar(); updateStarBtn(); }) .catch(e => toast('Fehler: ' + e.message)); } // ─── QUEUE PANEL ────────────────────────────────────────────────────────── let _renderedQueueKey = ''; export function renderQueue() { const el = document.getElementById('queue-list'); if (!el) return; const queueKey = S.queue.map(s => s.id).join(','); if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) { el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx)); el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); return; } _renderedQueueKey = queueKey; el.innerHTML = ''; if (!S.queue.length) { el.innerHTML = '
🎵

Leer

'; 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 = `
${i+1}
${cv ? `` : '♪'}
${esc(dispTitle(s))}
${esc(dispArtist(s))}
`; item.onclick = () => { S.qIdx = i; playCurrent(); }; el.appendChild(item); }); el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); } export function toggleQueue() { S.queueOpen = !S.queueOpen; document.getElementById('queue-panel').classList.toggle('open', S.queueOpen); if(S.queueOpen) renderQueue(); _syncOverlayState(); } // Called after showRomaji is toggled — re-renders all live player UI with new display mode. export function refreshDisplayMode() { _renderedQueueKey = ''; // force queue re-render if (S.queueOpen) renderQueue(); const s = S.queue[S.qIdx]; if (s) { updatePlayerUI(s, true); _syncNpDirect(s); } } // ─── DOWNLOAD ───────────────────────────────────────────────────────────── export function dlSongLocal(s) { const a = document.createElement('a'); a.href = downloadUrl(s.id); a.download = `${s.artist||'Unknown'} - ${s.title}.${s.suffix||'mp3'}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); toast(`⬇ ${s.title}`); } export function downloadCurrent() { if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]); } // ─── PREVIEW ────────────────────────────────────────────────────────────── export function playPreview(track) { if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; } const wasPlaying = HAS_BRIDGE ? _nativePlaying : !S.audio.paused; if(wasPlaying) { if (HAS_BRIDGE) _nsBridge('pause'); else S.audio.pause(); } S.previewCurrent = track; S.previewCurrent._resumeMain = wasPlaying; S.previewAudio.volume = S.audio.volume; S.previewAudio.src = track.previewUrl; S.previewAudio.play(); document.getElementById('preview-title').innerHTML = esc(track.trackName||'—') + '30s Preview'; document.getElementById('preview-sub').textContent = (track.artistName||'') + (track.collectionName?' · '+track.collectionName:''); const art = track.artworkUrl100 || track.artworkUrl60 || ''; document.getElementById('preview-art').innerHTML = art ? `` : '
♪
'; 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 => `${esc(a.name)}`) .join(' • ') : '—'; document.getElementById('player-album-name').textContent = dispAlbum(s); const art = document.getElementById('player-art'); const cv = coverUrl(s.coverArt, 256); art.innerHTML = cv ? `` : '
♪
'; 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 {} }