- playCurrent(): arm canplay listener BEFORE calling play() so it's never missed when play() hangs (common on locked Android — promise neither resolves nor rejects) - playCurrent(): on ANY play() failure reset audio element (src + load()) so the audio engine starts fresh and canplay fires reliably; previously only AbortError triggered the reset — NotAllowedError (audio focus lost on lock screen) did not - playCurrent(): single _armCanplay() helper replaces two separate guard/listener setups; re-arms after failure in case canplay already fired before the error - _restoreLastPlayed(): remove updateMediaSession() call — pre-setting stale song metadata caused Samsung to treat the first actual playCurrent() as a new session (old song metadata → new song metadata while playing = notification close/reopen) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
621 lines
26 KiB
JavaScript
621 lines
26 KiB
JavaScript
import { S } from './state.js';
|
|
import { esc, formatDur, toast } from './utils.js';
|
|
import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
|
|
// views.js/ui.js circular imports — safe (only used inside function bodies)
|
|
import { loadRecommendations } from './views.js';
|
|
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
|
|
|
|
// ─── 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 `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(name)}','${aid}')">${esc(name)}</span>`;
|
|
}).join('<span style="color:var(--text3)"> • </span>')
|
|
: '—';
|
|
document.getElementById('player-album-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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
|
updateStarBtn();
|
|
document.title = `${s.title} — NovaSonic`;
|
|
if (!_skipMediaSession) updateMediaSession(s);
|
|
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
|
|
try {
|
|
localStorage.setItem('ns_last_played', JSON.stringify({
|
|
song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId,
|
|
artistId:s.artistId, coverArt:s.coverArt, duration:s.duration, _starred:s._starred||false },
|
|
queue: S.queue.map(t => ({ id:t.id, title:t.title, artist:t.artist, album:t.album,
|
|
albumId:t.albumId, artistId:t.artistId, coverArt:t.coverArt,
|
|
duration:t.duration, _starred:t._starred||false })),
|
|
qIdx: S.qIdx,
|
|
}));
|
|
} catch {}
|
|
_syncNpDirect(s);
|
|
}
|
|
|
|
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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
|
}
|
|
_crossfadeNpBg(cv);
|
|
const t = document.getElementById('np-title'); if(t) t.textContent = s.title || '—';
|
|
const an = document.getElementById('np-artist-name');
|
|
if(an) {
|
|
const parts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean);
|
|
an.innerHTML = parts.length
|
|
? parts.map((name, i) => {
|
|
const aid = i === 0 ? (s.artistId || '') : '';
|
|
return `<span style="cursor:pointer" onclick="closeNowPlaying();s_goArtist(event,'${esc(name)}','${aid}')">${esc(name)}</span>`;
|
|
}).join('<span style="opacity:.5"> • </span>')
|
|
: '—';
|
|
}
|
|
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
|
|
? '<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);
|
|
}
|
|
// 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 = '<div class="empty-state" style="padding:40px 16px"><div class="icon">🎵</div><p>Leer</p></div>'; return; }
|
|
S.queue.forEach((s, i) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
|
const cv = coverUrl(s.coverArt, 60);
|
|
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(s.title)}</div><div class="qi-artist">${esc(s.artist||'')}</div></div>`;
|
|
item.onclick = () => { S.qIdx = i; playCurrent(); };
|
|
el.appendChild(item);
|
|
});
|
|
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
export function toggleQueue() {
|
|
S.queueOpen = !S.queueOpen;
|
|
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
|
|
if(S.queueOpen) renderQueue();
|
|
}
|
|
|
|
// ─── 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||'—') + '<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 && 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 `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(name)}','${aid}')">${esc(name)}</span>`;
|
|
}).join('<span style="color:var(--text3)"> • </span>')
|
|
: '—';
|
|
document.getElementById('player-album-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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
|
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 {}
|
|
}
|