import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; import { api, streamUrl, coverUrl, downloadUrl } from './api.js'; // views.js/ui.js circular imports — safe (only used inside function bodies) import { loadRecommendations } from './views.js'; import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js'; // ─── COVER ART URL CACHE ─── // Stable URLs let the OS cache artwork images — without this every skip regenerates a new // auth token URL, Samsung has to re-fetch the image, and the lock screen notification // visually reloads. Defined here (top of file) so both preload and MediaSession can use it. 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 ─── // Reassigning audioSession.type on every track change resets the OS audio session, // which can cut background network briefly — exactly what causes Doze to kill autoplay. let _audioSessionSet = false; function _setAudioSession() { if (_audioSessionSet) return; _audioSessionSet = true; try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} } // ─── PRELOAD (resource hint keeps connection open through Doze gap) ─── let _preUrl = ''; let _preId = ''; let _preLink = null; function _nextIdx() { if(S.repeat === 'one') return S.qIdx; if(S.shuffle) return -1; if(S.qIdx + 1 < S.queue.length) return S.qIdx + 1; if(S.repeat === 'all') return 0; return -1; } function _preloadNext() { const ni = _nextIdx(); if(ni < 0) return; const next = S.queue[ni]; if(!next || _preId === next.id) return; _preId = next.id; _preUrl = streamUrl(next.id); // generate URL once — reused by main element if(_preLink) _preLink.remove(); _preLink = document.createElement('link'); _preLink.rel = 'prefetch'; _preLink.as = 'fetch'; _preLink.href = _preUrl; document.head.appendChild(_preLink); // Pre-warm cover art so lock screen notification never cold-starts artwork on first skip. // Uses _cachedCoverUrl so the same token is reused when updateMediaSession() runs later. if(next.coverArt) { [96, 300, 512].forEach(size => { const img = new Image(); img.src = _cachedCoverUrl(next.coverArt, size); }); } } function _clearPreload() { _preId = ''; _preUrl = ''; if(_preLink) { _preLink.remove(); _preLink = null; } } // ─── PLAYBACK ─── let _playGen = 0; // incremented on every playCurrent() — stale callbacks check this export function playQueue(songs, startIdx) { S.queue = songs.map(s => ({...s})); S.qIdx = startIdx; _clearPreload(); 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]; const gen = ++_playGen; S._trackChanging = true; // Hold notification open with 'playing' state BEFORE touching metadata or src if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; // DOM update only — MediaSession metadata deferred until audio actually starts. // Changing metadata during the HAVE_NOTHING window (after src swap) causes Chrome to // signal "stopped" to Android, making Samsung close and re-open the lock screen card. updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); _clearPreload(); S.audio.src = src; const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; // Called once per transition when audio is confirmed playing const _applySession = () => { if (_playGen !== gen) return; updateMediaSession(s); setTimeout(_preloadNext, 2000); }; // ── canplay-first recovery (armed BEFORE play() call) ────────────────────── // canplay fires from the audio engine — not throttled by Android Doze or JS // timer limits — so this is the only reliable wakeup on a locked screen. // We arm it now so it's ready whether play() hangs, fails, or never resolves. let _guardTimer = null; let _canplayFn = null; function _armCanplay() { if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); clearTimeout(_guardTimer); _guardTimer = setTimeout(() => { // canplay didn't fire in 10 s — Doze likely stalled the network. // Try play() anyway; _onForeground() will retry if the user opens the app. if (_playGen !== gen) return; S._trackChanging = false; S.audio.play().then(_applySession).catch(() => {}); }, 10000); _canplayFn = () => { clearTimeout(_guardTimer); _canplayFn = null; 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(); // arm before play() so we never miss the canplay event _setAudioSession(); S.audio.play() .then(() => { // Immediate success — cancel canplay guard, apply session now clearTimeout(_guardTimer); if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); _applySession(); }) .catch(err => { if (_playGen !== gen) return; // Any failure (AbortError, NotAllowedError, etc.): reset the element so // the audio engine starts fresh and canplay fires reliably. // _trackChanging stays true so load()'s pause event stays suppressed. S.audio.src = src; S.audio.load(); _armCanplay(); // re-arm: canplay may have already fired once before the error }); } export function updatePlayerUI(s, _skipMediaSession = false) { document.getElementById('player-title').textContent = s.title; const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean); document.getElementById('player-artist-name').innerHTML = artistParts.length ? artistParts.map((name, i) => { const aid = i === 0 ? (s.artistId || '') : ''; return `${esc(name)}`; }).join(' • ') : '—'; document.getElementById('player-album-sep').style.display = s.album ? '' : 'none'; document.getElementById('player-album-name').textContent = s.album || ''; const art = document.getElementById('player-art'); const cv = coverUrl(s.coverArt, 100); const existingImg = art.querySelector('img'); if (cv && existingImg) 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(); try { localStorage.setItem('ns_last_played', JSON.stringify({ song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId, artistId:s.artistId, coverArt:s.coverArt, duration:s.duration, _starred:s._starred||false }, queue: S.queue.map(t => ({ id:t.id, title:t.title, artist:t.artist, album:t.album, albumId:t.albumId, artistId:t.artistId, coverArt:t.coverArt, duration:t.duration, _starred:t._starred||false })), qIdx: S.qIdx, })); } catch {} _syncNpDirect(s); } let _npBgCurr = 'a'; let _npBgLastUrl = ''; function _crossfadeNpBg(cv) { if ((cv || '') === _npBgLastUrl) return; _npBgLastUrl = cv || ''; const doFade = () => { const currEl = document.getElementById(`np-bg-${_npBgCurr}`); const nextId = _npBgCurr === 'a' ? 'b' : 'a'; const nextEl = document.getElementById(`np-bg-${nextId}`); if (!currEl || !nextEl) return; nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none'; // Double rAF: wait for browser to paint the new bg (GPU decode) before fading 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, 400); const cover = document.getElementById('np-cover'); if (cover) { const existingImg = cover.querySelector('img'); if (cv && existingImg) existingImg.src = cv; else if (cover) cover.innerHTML = cv ? `` : '
♪
'; } _crossfadeNpBg(cv); const t = document.getElementById('np-title'); if(t) t.textContent = s.title || '—'; const an = document.getElementById('np-artist-name'); if(an) { const parts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean); an.innerHTML = parts.length ? parts.map((name, i) => { const aid = i === 0 ? (s.artistId || '') : ''; return `${esc(name)}`; }).join(' • ') : '—'; } const asep = document.getElementById('np-album-sep'); if(asep) asep.style.display = s.album ? '' : 'none'; const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = s.album || ''; syncNpStar(); } // ─── TRANSPORT ─── export function togglePlay() { if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); } 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); } // Android native MediaSession bridge if (window.Android) { try { window.Android.setPlaybackState(playing); } catch(_) {} } } export function prevSong() { _clearPreload(); if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; } if(S.qIdx > 0) { S.qIdx--; playCurrent(); } } export function nextSong() { _clearPreload(); if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length); else if(S.qIdx < S.queue.length - 1) S.qIdx++; else if(S.repeat) S.qIdx = 0; 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; } 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'); _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(); } // ─── 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(); } export async function fillRadioQueue() { if(!S.radioMode) return; try { const r = await api('getRandomSongs', '&size=20'); S.queue.push(...(r.randomSongs?.song || [])); renderQueue(); } catch {} } // ─── PROGRESS & SCRUBBING ─── let _androidPosTick = 0; export function updateProgress() { if(!S.audio.duration || S._scrubbing) return; syncMediaSessionPosition(); // Preload next track at 30s remaining so network stays alive through Doze gap const rem = S.audio.duration - S.audio.currentTime; if(rem > 0 && rem < 30 && !S._trackChanging) _preloadNext(); // Update Android native position every ~5 seconds to keep lock screen scrubber in sync const now = Date.now(); if(now - _androidPosTick > 5000) { _androidPosTick = now; if (window.Android) { try { window.Android.updatePosition(S.audio.currentTime, S.audio.duration); } catch(_) {} } } const p = (S.audio.currentTime / S.audio.duration) * 100; document.getElementById('progress-fill').style.width = p + '%'; document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime); document.getElementById('time-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'); if (!bar || !S.audio.duration) 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.audio.currentTime = pos * S.audio.duration; S._scrubbing = true; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; S.audio.addEventListener('seeked', done, { once: true }); setTimeout(done, 1500); } export function initProgressScrub() { _initBarScrub( document.getElementById('progress-bar'), document.getElementById('progress-fill'), pos => { document.getElementById('time-cur').textContent = formatDur(pos * S.audio.duration); } ); _initBarScrub( document.getElementById('np-bar'), document.getElementById('np-fill'), pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * S.audio.duration); } ); } function _initBarScrub(bar, fill, onMove) { 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) + '%'; // pin visual position before re-enabling transition if(S.audio.duration) { S.audio.currentTime = pos * S.audio.duration; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); }; S.audio.addEventListener('seeked', done, { once: true }); setTimeout(done, 1500); // fallback if seeked never fires } else { S._scrubbing = false; fill.style.transition = ''; } } bar.addEventListener('touchstart', e => { startScrub(getPos(e.touches[0].clientX)); e.stopPropagation(); }, {passive:true}); bar.addEventListener('touchmove', e => { if(S._scrubbing) moveScrub(getPos(e.touches[0].clientX)); }, {passive:true}); bar.addEventListener('touchend', e => { if(S._scrubbing) endScrub(getPos(e.changedTouches[0].clientX)); }, {passive:true}); bar.addEventListener('mousedown', e => { 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('mousemove',mm); document.removeEventListener('mouseup',mu); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } export function setVolume(v) { const vol = v / 100; S.audio.volume = vol; S.previewAudio.volume = vol; const absAudio = document.getElementById('abs-audio'); if (absAudio) absAudio.volume = vol; // sync all volume sliders document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; }); } // ─── MEDIA SESSION ─── export function updateMediaSession(s) { // Android native MediaSession bridge — use 512px art for high-DPI screens if (window.Android) { try { window.Android.updateTrack( s.title || '', s.artist || '', s.coverArt ? coverUrl(s.coverArt, 512) : '', s.duration || 0 ); } catch(_) {} } if(!navigator.mediaSession) return; const artwork = s.coverArt ? [ { src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 300), sizes:'300x300', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, ] : []; navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); navigator.mediaSession.setActionHandler('play', togglePlay); navigator.mediaSession.setActionHandler('pause', togglePlay); navigator.mediaSession.setActionHandler('previoustrack', prevSong); navigator.mediaSession.setActionHandler('nexttrack', nextSong); navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); } export function syncMediaSessionPosition() { if(!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) { // Queue unchanged — only update active class in-place (no image reload) el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx)); el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); return; } _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, 60); item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`; 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(); } // ─── 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 = !S.audio.paused; if(wasPlaying) 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 && 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 ─── export function _restoreLastPlayed() { try { const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); if(!saved || !saved.song) return; S.queue = saved.queue || [saved.song]; S.qIdx = saved.qIdx || 0; const s = S.queue[S.qIdx]; if(!s) return; document.getElementById('player-title').textContent = s.title || '—'; const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean); document.getElementById('player-artist-name').innerHTML = artistParts.length ? artistParts.map((name, i) => { const aid = i === 0 ? (s.artistId || '') : ''; return `${esc(name)}`; }).join(' • ') : '—'; document.getElementById('player-album-sep').style.display = s.album ? '' : 'none'; document.getElementById('player-album-name').textContent = s.album || ''; const art = document.getElementById('player-art'); const cv = coverUrl(s.coverArt, 100); art.innerHTML = cv ? `` : '
♪
'; updateStarBtn(); document.title = `${s.title} — NovaSonic`; // set src so the play button works immediately without needing to re-select the song S.audio.src = streamUrl(s.id); S.audio.load(); // Do NOT call updateMediaSession() here — setting stale metadata before the user // starts playing means playCurrent()'s _applySession() later looks like a "new session" // to Samsung (old song → new song metadata while playing → notification resets). } catch {} }