Files
NovaSonic/app.js
T
HoneyandClaude Sonnet 4.6 ce53c48835 fix: playlist cover auto-refresh after upload
Re-generate coverUrl (includes fresh auth params) + append _cb=timestamp
to bypass browser cache. Update both detail view and edit modal preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 02:06:41 +02:00

1857 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── MD5 (correct implementation) ───
function md5(input) {
function safeAdd(x,y){var l=(x&0xffff)+(y&0xffff);return((x>>16)+(y>>16)+(l>>16)<<16)|(l&0xffff)}
function rol(n,c){return(n<<c)|(n>>>32-c)}
function cmn(q,a,b,x,s,t){return safeAdd(rol(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)}
function ff(a,b,c,d,x,s,t){return cmn((b&c)|(~b&d),a,b,x,s,t)}
function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&~d),a,b,x,s,t)}
function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t)}
function ii(a,b,c,d,x,s,t){return cmn(c^(b|~d),a,b,x,s,t)}
// Convert string to little-endian word array
var l=input.length, m=[];
for(var i=0;i<l;i++) m[i>>2]|=(input.charCodeAt(i)&0xff)<<((i%4)*8);
m[l>>2]|=0x80<<((l%4)*8);
m[((l+8>>6)+1)*16-2]=l*8;
// Initial hash values
var a=1732584193,b=-271733879,c=-1732584194,d=271733878;
for(var i=0;i<m.length;i+=16){
var A=a,B=b,C=c,D=d;
a=ff(a,b,c,d,m[i+0],7,-680876936); d=ff(d,a,b,c,m[i+1],12,-389564586); c=ff(c,d,a,b,m[i+2],17,606105819); b=ff(b,c,d,a,m[i+3],22,-1044525330);
a=ff(a,b,c,d,m[i+4],7,-176418897); d=ff(d,a,b,c,m[i+5],12,1200080426); c=ff(c,d,a,b,m[i+6],17,-1473231341); b=ff(b,c,d,a,m[i+7],22,-45705983);
a=ff(a,b,c,d,m[i+8],7,1770035416); d=ff(d,a,b,c,m[i+9],12,-1958414417); c=ff(c,d,a,b,m[i+10],17,-42063); b=ff(b,c,d,a,m[i+11],22,-1990404162);
a=ff(a,b,c,d,m[i+12],7,1804603682); d=ff(d,a,b,c,m[i+13],12,-40341101); c=ff(c,d,a,b,m[i+14],17,-1502002290); b=ff(b,c,d,a,m[i+15],22,1236535329);
a=gg(a,b,c,d,m[i+1],5,-165796510); d=gg(d,a,b,c,m[i+6],9,-1069501632); c=gg(c,d,a,b,m[i+11],14,643717713); b=gg(b,c,d,a,m[i+0],20,-373897302);
a=gg(a,b,c,d,m[i+5],5,-701558691); d=gg(d,a,b,c,m[i+10],9,38016083); c=gg(c,d,a,b,m[i+15],14,-660478335); b=gg(b,c,d,a,m[i+4],20,-405537848);
a=gg(a,b,c,d,m[i+9],5,568446438); d=gg(d,a,b,c,m[i+14],9,-1019803690); c=gg(c,d,a,b,m[i+3],14,-187363961); b=gg(b,c,d,a,m[i+8],20,1163531501);
a=gg(a,b,c,d,m[i+13],5,-1444681467); d=gg(d,a,b,c,m[i+2],9,-51403784); c=gg(c,d,a,b,m[i+7],14,1735328473); b=gg(b,c,d,a,m[i+12],20,-1926607734);
a=hh(a,b,c,d,m[i+5],4,-378558); d=hh(d,a,b,c,m[i+8],11,-2022574463); c=hh(c,d,a,b,m[i+11],16,1839030562); b=hh(b,c,d,a,m[i+14],23,-35309556);
a=hh(a,b,c,d,m[i+1],4,-1530992060); d=hh(d,a,b,c,m[i+4],11,1272893353); c=hh(c,d,a,b,m[i+7],16,-155497632); b=hh(b,c,d,a,m[i+10],23,-1094730640);
a=hh(a,b,c,d,m[i+13],4,681279174); d=hh(d,a,b,c,m[i+0],11,-358537222); c=hh(c,d,a,b,m[i+3],16,-722521979); b=hh(b,c,d,a,m[i+6],23,76029189);
a=hh(a,b,c,d,m[i+9],4,-640364487); d=hh(d,a,b,c,m[i+12],11,-421815835); c=hh(c,d,a,b,m[i+15],16,530742520); b=hh(b,c,d,a,m[i+2],23,-995338651);
a=ii(a,b,c,d,m[i+0],6,-198630844); d=ii(d,a,b,c,m[i+7],10,1126891415); c=ii(c,d,a,b,m[i+14],15,-1416354905); b=ii(b,c,d,a,m[i+5],21,-57434055);
a=ii(a,b,c,d,m[i+12],6,1700485571); d=ii(d,a,b,c,m[i+3],10,-1894986606); c=ii(c,d,a,b,m[i+10],15,-1051523); b=ii(b,c,d,a,m[i+1],21,-2054922799);
a=ii(a,b,c,d,m[i+8],6,1873313359); d=ii(d,a,b,c,m[i+15],10,-30611744); c=ii(c,d,a,b,m[i+6],15,-1560198380); b=ii(b,c,d,a,m[i+13],21,1309151649);
a=ii(a,b,c,d,m[i+4],6,-145523070); d=ii(d,a,b,c,m[i+11],10,-1120210379);c=ii(c,d,a,b,m[i+2],15,718787259); b=ii(b,c,d,a,m[i+9],21,-343485551);
a=safeAdd(a,A);b=safeAdd(b,B);c=safeAdd(c,C);d=safeAdd(d,D);
}
function hex(n){var s='';for(var j=0;j<4;j++)s+=('0'+((n>>>(j*8))&0xff).toString(16)).slice(-2);return s}
return hex(a)+hex(b)+hex(c)+hex(d);
}
// ─── STATE ───
let cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''};
let queue=[],qIdx=-1,shuffle=false,repeat=false;
let searchTimer=null,globalSearchTimer=null;
let downloadTarget=null,dlType='track';
let jobs={};
const audio=document.getElementById('audio-element');
const previewAudio=document.getElementById('preview-audio');
// ─── SETUP TABS ───
function switchSetupTab(t){
document.querySelectorAll('.setup-tab').forEach((el,i)=>el.classList.toggle('active',i===(t==='navidrome'?0:1)));
document.querySelectorAll('.setup-pane').forEach((el,i)=>el.classList.toggle('active',i===(t==='navidrome'?0:1)));
}
// ─── SUBSONIC API ───
function apiUrl(method,extra=''){
const salt=Math.random().toString(36).slice(2,10);
const token=md5(cfg.pass+salt);
return `${cfg.url}/rest/${method}?u=${encodeURIComponent(cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
}
async function api(method,extra=''){
const res=await fetch(apiUrl(method,extra));
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const data=await res.json();
const r=data['subsonic-response'];
if(r.status!=='ok') throw new Error(r.error?.message||'API Error');
return r;
}
function streamUrl(id){return apiUrl('stream',`&id=${id}&format=raw`)}
function downloadUrl(id){return apiUrl('download',`&id=${id}`)}
function coverUrl(id,size=300){return id?apiUrl('getCoverArt',`&id=${id}&size=${size}`):null}
// ─── CONNECT ───
async function doConnect(){
const url=document.getElementById('srv-url').value.trim().replace(/\/$/,'');
const user=document.getElementById('srv-user').value.trim();
const pass=document.getElementById('srv-pass').value;
const lfKey=document.getElementById('lastfm-key').value.trim();
const backUrl=document.getElementById('backend-url').value.trim().replace(/\/$/,'');
if(!url||!user||!pass){showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.');return}
const btn=document.getElementById('btn-connect');
btn.disabled=true;btn.textContent='Verbinde…';
document.getElementById('setup-error').style.display='none';
cfg={url,user,pass,lastfmKey:lfKey,backendUrl:backUrl};
try{
await api('ping');
localStorage.setItem('ns_cfg',JSON.stringify(cfg));
startApp();
}catch(e){
cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''};
showSetupError('Verbindung fehlgeschlagen: '+e.message);
btn.disabled=false;btn.textContent='Verbinden';
}
}
function showSetupError(m){const el=document.getElementById('setup-error');el.textContent=m;el.style.display='block'}
function disconnect(){
localStorage.removeItem('ns_cfg');
localStorage.removeItem('ns_last_played');
sessionStorage.removeItem('ns_nav');
cfg={url:'',user:'',pass:'',lastfmKey:'',backendUrl:''};
queue=[];qIdx=-1;_navStack=[];_navCursor=-1;
audio.pause();audio.src='';
document.getElementById('app').style.display='none';
document.getElementById('setup-screen').style.display='flex';
}
// ─── INIT ───
window.addEventListener('load',()=>{
const saved=localStorage.getItem('ns_cfg');
if(saved){cfg=JSON.parse(saved);startApp()}
// Autofill URLs from Docker env (set via config.js)
if(window.NS_SERVER_URL?.trim()){const el=document.getElementById('srv-url');if(el&&!el.value)el.value=window.NS_SERVER_URL.trim();}
if(window.NS_BACKEND_URL?.trim()){const el=document.getElementById('backend-url');if(el&&!el.value)el.value=window.NS_BACKEND_URL.trim();}
document.getElementById('srv-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doConnect()});
audio.addEventListener('timeupdate',updateProgress);
audio.addEventListener('ended',onEnded);
audio.addEventListener('play',()=>{setPlayIcon(true);document.getElementById('player-art').classList.add('spinning');if(navigator.mediaSession)navigator.mediaSession.playbackState='playing';});
audio.addEventListener('pause',()=>{setPlayIcon(false);document.getElementById('player-art').classList.remove('spinning');if(navigator.mediaSession)navigator.mediaSession.playbackState='paused';});
previewAudio.addEventListener('timeupdate',updatePreviewProgress);
previewAudio.addEventListener('ended',stopPreview);
audio.volume=0.8;
injectPWA();
initProgressScrub();
// Now Playing: click player-art to open
const pa=document.getElementById('player-art');
if(pa)pa.addEventListener('click', openNowPlaying);
// Swipe-up on player bar to open Now Playing
const playerBar=document.querySelector('.player');
if(playerBar){
let _sy=0,_st=0;
playerBar.addEventListener('touchstart',e=>{
if(e.target.closest('button')||e.target.closest('.progress-bar')||e.target.closest('.volume-wrap'))return;
_sy=e.touches[0].clientY;_st=Date.now();
},{passive:true});
playerBar.addEventListener('touchend',e=>{
if(!_sy)return;
const dy=_sy-e.changedTouches[0].clientY;
const dt=Date.now()-_st;
if(dy>40||(dt<300&&dy>15))openNowPlaying();
_sy=0;
},{passive:true});
}
});
async function startApp(){
document.getElementById('setup-screen').style.display='none';
document.getElementById('app').style.display='flex';
// Restore last navigation position (survives soft refresh)
if (_navLoad() && _navCursor >= 0 && _navStack[_navCursor]) {
const last = _navStack[_navCursor];
_navJumping = true;
navigate(last.page, last.data);
_navJumping = false;
} else {
navigate('home');
}
loadHomeData();
// Restore last played song (survives tab close)
_restoreLastPlayed();
// Restore radio mode state
if(localStorage.getItem('ns_radio')==='1'){radioMode=true;_applyRadioBtn();}
}
function _restoreLastPlayed() {
try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
if (!saved || !saved.song) return;
// Restore queue state silently (don't auto-play, just show in player bar)
queue = saved.queue || [saved.song];
qIdx = saved.qIdx || 0;
const s = queue[qIdx];
if (!s) return;
// Update player UI without starting playback
document.getElementById('player-title').textContent = s.title || '—';
document.getElementById('player-artist-name').textContent = s.artist || '—';
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`;
} catch {}
}
// ─── NAVIGATION HISTORY ─────────────────────────────────────────
// Cursor-based stack: never destructively pop — just move the cursor.
// Stored in sessionStorage so soft reloads survive; tabs start fresh.
let _navStack = []; // [{page, data}, ...]
let _navCursor = -1; // points to current entry
let _navJumping = false; // true while we're programmatically changing cursor
function _navSave() {
try {
sessionStorage.setItem('ns_nav', JSON.stringify({stack:_navStack, cursor:_navCursor}));
} catch {}
}
function _navLoad() {
try {
const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null');
if (s && Array.isArray(s.stack) && s.stack.length) {
_navStack = s.stack;
_navCursor = s.cursor;
return true;
}
} catch {}
return false;
}
function navigate(page, data=null) {
// ── Core page switch ──
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`);
if (pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
closeSidebar();
if(['discover','new-releases','recommendations','random'].includes(page))switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page))switchNavMode('library');
// ── Load data ──
if (page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists();
if (page==='albums' && !document.getElementById('albums-grid').children.length) loadAlbums();
if (page==='starred') loadStarred();
if (page==='random') loadRandom();
if (page==='artist-detail' && data) loadArtistDetail(data);
if (page==='album-detail' && data) loadAlbumDetail(data);
if (page==='new-releases') loadNewReleases();
if (page==='playlists') loadPlaylists();
if (page==='playlist-detail' && data) loadPlaylistDetail(data);
if (page==='recommendations') loadRecommendations();
// ── History stack ──
if (!_navJumping) {
// Truncate any forward entries (user navigated after going back)
_navStack = _navStack.slice(0, _navCursor + 1);
// Avoid duplicate consecutive identical entries
const safe = data ? JSON.parse(JSON.stringify(data)) : null;
const last = _navStack[_navCursor];
if (!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) {
_navStack.push({ page, data: safe });
}
_navCursor = _navStack.length - 1;
_navSave();
// Keep ONE real browser history entry ahead so popstate fires on back
try { history.pushState({ nsIdx: _navCursor }, '', ''); } catch {}
}
}
function _navGoBack() {
// Dismiss overlays first (sheet → NP → then real back)
if (document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; }
if (npOpen) { closeNowPlaying(); return; }
if (_navCursor > 0) {
_navCursor--;
const entry = _navStack[_navCursor];
_navSave();
_navJumping = true;
navigate(entry.page, entry.data);
_navJumping = false;
}
// Always keep a pushState ahead so next back also fires popstate
try { history.pushState({ nsIdx: _navCursor }, '', ''); } catch {}
}
window.addEventListener('popstate', () => _navGoBack());
document.addEventListener('keydown', e => {
if (e.key !== 'Backspace') return;
const t = document.activeElement?.tagName;
if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return;
e.preventDefault();
_navGoBack();
});
// Seed an initial browser history entry so the very first back is caught
try { history.replaceState({ nsIdx: 0 }, '', ''); } catch {}
async function loadArtistDetail(artist) {
document.getElementById('artist-detail-name').textContent = artist.name;
document.getElementById('artist-back-btn').onclick = () => navigate('artists');
const hero = document.getElementById('artist-detail-hero');
hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))';
// Clear Last.fm sections
['artist-bio-section','artist-similar-section','artist-topsongs-section'].forEach(id=>{
const el=document.getElementById(id); if(el)el.innerHTML='';
});
const g = document.getElementById('artist-albums-grid');
g.innerHTML = '<div class="spinner"></div>';
// Parallel: image + albums + last.fm info
const [imgRes, albumRes, infoRes, topRes] = await Promise.allSettled([
getArtistImage(artist.id),
api('getArtist', `&id=${artist.id}`),
api('getArtistInfo2', `&id=${artist.id}&count=8`),
api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`)
]);
// Hero image
const img = imgRes.status==='fulfilled' ? imgRes.value : null;
if (img) {
hero.style.background = `url('${img}') center/cover no-repeat`;
hero.querySelector('.artist-hero-overlay').style.background =
'linear-gradient(to right,rgba(7,7,15,.9) 0%,rgba(7,7,15,.5) 60%,rgba(7,7,15,.2) 100%)';
}
// Albums
if (albumRes.status==='fulfilled') {
g.innerHTML = '';
(albumRes.value.artist?.album || []).forEach(a=>g.appendChild(makeAlbumCard({...a,_fromArtist:artist})));
} else {
g.innerHTML = `<p style="color:var(--danger)">${albumRes.reason?.message}</p>`;
}
// ── Last.fm: bio
const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null;
if (info?.biography) {
const bioEl = document.getElementById('artist-bio-section');
// Strip Last.fm HTML links, keep plain text
const bio = info.biography.replace(/<a [^>]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim();
const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio;
if (short && bioEl) {
window._artistBioFull = bio;
bioEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Über</div>
<div class="artist-bio" id="artist-bio-text">${esc(short)}${bio.length>400
?` <button onclick="expandArtistBio()" style="background:none;border:none;cursor:pointer;color:var(--accent);font-size:12px;padding:0;margin-left:6px">Mehr lesen</button>`
:''}</div>`;
}
}
// ── Last.fm: similar artists
const similar = info?.similarArtist || [];
if (similar.length) {
const simEl = document.getElementById('artist-similar-section');
if (simEl) {
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
<div class="similar-chips">${similar.map(a=>
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
${esc(a.name)}
</span>`).join('')}
</div>`;
}
}
// ── Last.fm / Navidrome: top songs
const topSongs = topRes.status==='fulfilled' ? (topRes.value.topSongs?.song||[]) : [];
if (topSongs.length) {
const tsEl = document.getElementById('artist-topsongs-section');
if (tsEl) {
tsEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Top Songs</div>`;
const list = document.createElement('div');
list.style.marginBottom='20px';
topSongs.forEach((s,i)=>{
const cv = coverUrl(s.coverArt, 60);
const row = document.createElement('div');
row.className='topsong-item';
row.innerHTML=`<div class="topsong-num">${i+1}</div>
<div class="topsong-thumb">${cv?`<img src="${cv}" loading="lazy">`:'<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
<div><div class="topsong-title">${esc(s.title)}</div><div style="font-size:11px;color:var(--text2)">${esc(s.album||'')}</div></div>
<div class="topsong-plays">${s.playCount?s.playCount.toLocaleString():'—'}</div>`;
row.onclick=()=>playQueue(topSongs,i);
row.addEventListener('contextmenu',e=>{e.preventDefault();showCtxMenu(e,s,topSongs,i)});
list.appendChild(row);
});
tsEl.appendChild(list);
}
}
}
async function loadAlbums(){
const g=document.getElementById('albums-grid');g.innerHTML='<div class="spinner"></div>';
try{const r=await api('getAlbumList2','&type=alphabeticalByName&size=500');g.innerHTML='';(r.albumList2?.album||[]).forEach(a=>g.appendChild(makeAlbumCard(a)))}
catch(e){g.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
async function loadAlbumDetail(album){
document.getElementById('album-back-btn').onclick=()=>album._fromArtist?navigate('artist-detail',album._fromArtist):navigate('albums');
document.getElementById('album-detail-name').textContent=album.name;
const artistEl=document.getElementById('album-detail-artist');artistEl.textContent=album.artist||'';
artistEl.onclick=()=>{if(album.artistId)navigate('artist-detail',{id:album.artistId,name:album.artist})};
const cw=document.getElementById('album-cover-wrap');
const cv=coverUrl(album.coverArt,300);
cw.innerHTML=cv?`<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>💿</div>'">`:'<div class="no-art">💿</div>';
const sl=document.getElementById('album-songs');sl.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getAlbum',`&id=${album.id}`);
const songs=r.album?.song||[];
const totalMin=Math.round(songs.reduce((s,t)=>s+(t.duration||0),0)/60);
document.getElementById('album-detail-stats').textContent=`${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
document.getElementById('btn-play-album').onclick=()=>playQueue(songs,0);
document.getElementById('btn-dl-album').onclick=()=>{songs.forEach((s,i)=>setTimeout(()=>dlSongLocal(s),i*400));toast(`⬇ ${songs.length} Titel Download gestartet`)};
sl.innerHTML='';songs.forEach((s,i)=>sl.appendChild(makeSongItem(s,i,songs)));
}catch(e){sl.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── PLAYLISTS ───
async function loadPlaylists(){
const g=document.getElementById('playlists-grid');g.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getPlaylists');
const lists=r.playlists?.playlist||[];
g.innerHTML='';
if(!lists.length){g.innerHTML='<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>';return}
lists.forEach(pl=>{
const cv=coverUrl(pl.coverArt,200);
const c=makeCard(pl.name,pl.songCount+' Titel',cv,'🎶');
c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();quickPlayPlaylist(pl)};
c.onclick=()=>navigate('playlist-detail',pl);
g.appendChild(c);
});
}catch(e){g.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
async function quickPlayPlaylist(pl){
try{const r=await api('getPlaylist',`&id=${pl.id}`);const songs=r.playlist?.entry||[];playQueue(songs,0)}
catch(e){toast('Fehler: '+e.message)}
}
let _currentPlaylist=null;
async function loadPlaylistDetail(pl){
_currentPlaylist={...pl};
document.getElementById('playlist-detail-name').textContent=pl.name;
const cw=document.getElementById('playlist-cover');
const cv=coverUrl(pl.coverArt,300);
cw.innerHTML=cv?`<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\'no-art\'>🎶</div>'">`:'<div class="no-art">🎶</div>';
document.getElementById('btn-edit-playlist').onclick=()=>openEditPlModal(_currentPlaylist);
const sl=document.getElementById('playlist-songs');sl.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getPlaylist',`&id=${pl.id}`);
const full=r.playlist||{};
_currentPlaylist={...pl,...full,id:pl.id};
const songs=full.entry||[];
const totalMin=Math.round(songs.reduce((s,t)=>s+(t.duration||0),0)/60);
document.getElementById('playlist-detail-stats').textContent=`${songs.length} Titel · ${totalMin} Min.`;
const descEl=document.getElementById('playlist-detail-desc');
if(descEl){descEl.textContent=full.comment||'';descEl.style.display=full.comment?'':'none';}
document.getElementById('btn-play-playlist').onclick=()=>playQueue(songs,0);
document.getElementById('btn-shuffle-playlist').onclick=()=>{
const shuffled=[...songs].sort(()=>Math.random()-.5);
playQueue(shuffled,0);
};
sl.innerHTML='';songs.forEach((s,i)=>sl.appendChild(makeSongItem(s,i,songs)));
}catch(e){sl.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── EDIT PLAYLIST ───
function openEditPlModal(pl){
const overlay=document.getElementById('edit-pl-overlay');
document.getElementById('edit-pl-name').value=pl.name||'';
document.getElementById('edit-pl-desc').value=pl.comment||'';
const tog=document.getElementById('edit-pl-public-toggle');
tog.classList.toggle('on',!!pl.public);
const cv=coverUrl(pl.coverArt,300);
document.getElementById('edit-pl-cover').innerHTML=cv
?`<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
:'<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
overlay.classList.add('open');
}
function closeEditPlModal(){document.getElementById('edit-pl-overlay').classList.remove('open')}
async function saveEditPlaylist(){
const pl=_currentPlaylist;if(!pl)return;
const name=document.getElementById('edit-pl-name').value.trim();
if(!name){toast('Name darf nicht leer sein');return;}
const comment=document.getElementById('edit-pl-desc').value.trim();
const pub=document.getElementById('edit-pl-public-toggle').classList.contains('on');
const btn=document.querySelector('.edit-pl-btn-save');
btn.disabled=true;btn.textContent='Speichern…';
try{
await api('updatePlaylist',`&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
_currentPlaylist={..._currentPlaylist,name,comment,public:pub};
document.getElementById('playlist-detail-name').textContent=name;
closeEditPlModal();
toast('Playlist aktualisiert');
}catch(e){toast('Fehler: '+e.message)}
finally{btn.disabled=false;btn.textContent='Speichern';}
}
let _ndToken=null;
async function ndAuth(){
if(_ndToken)return _ndToken;
const res=await fetch(`${cfg.url}/auth/login`,{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:cfg.user,password:cfg.pass})
});
if(!res.ok)throw new Error('Navidrome login fehlgeschlagen');
const d=await res.json();
_ndToken=d.token;
return _ndToken;
}
function editPlUploadCover(){document.getElementById('edit-pl-file').click()}
async function editPlCoverSelected(input){
const file=input.files?.[0];if(!file||!_currentPlaylist)return;
input.value='';
const preview=URL.createObjectURL(file);
document.getElementById('edit-pl-cover').innerHTML=`<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
try{
const token=await ndAuth();
const form=new FormData();form.append('image',file,file.name);
const res=await fetch(`${cfg.url}/api/playlist/${_currentPlaylist.id}/image`,{
method:'POST',
headers:{'X-Nd-Authorization':'Bearer '+token},
body:form
});
if(!res.ok)throw new Error(res.status);
toast('Cover aktualisiert');
setTimeout(()=>{
const bust=coverUrl(_currentPlaylist.coverArt,300)+'&_cb='+Date.now();
const detailImg=document.getElementById('playlist-cover');
if(detailImg)detailImg.innerHTML=`<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
document.getElementById('edit-pl-cover').innerHTML=`<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
},800);
}catch(e){
_ndToken=null;
toast('Cover-Upload fehlgeschlagen: '+e.message);
document.getElementById('edit-pl-cover').innerHTML=
(coverUrl(_currentPlaylist.coverArt,300)
?`<img src="${coverUrl(_currentPlaylist.coverArt,300)}" style="width:100%;height:100%;object-fit:cover">`
:'<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>');
}
}
async function editPlRemoveCover(){
if(!_currentPlaylist)return;
try{
const token=await ndAuth();
const res=await fetch(`${cfg.url}/api/playlist/${_currentPlaylist.id}/image`,{
method:'DELETE',headers:{'X-Nd-Authorization':'Bearer '+token}
});
if(!res.ok)throw new Error(res.status);
toast('Cover entfernt');
_currentPlaylist.coverArt=null;
document.getElementById('edit-pl-cover').innerHTML='<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
document.getElementById('playlist-cover').innerHTML='<div class="no-art">🎶</div>';
}catch(e){_ndToken=null;toast('Fehler: '+e.message)}
}
// ─── RADIO MODE (endloser Shuffle aus der eigenen Bibliothek) ───
let radioMode=false;
function _applyRadioBtn(){
const btn=document.getElementById('btn-radio-mode');
if(btn){btn.classList.toggle('active',radioMode);btn.style.color=radioMode?'var(--blue)':'';}
const nb=document.getElementById('np-radio-btn');
if(nb){nb.classList.toggle('active',radioMode);nb.style.color=radioMode?'var(--blue)':'';}
}
async function toggleRadioMode(){
radioMode=!radioMode;
localStorage.setItem('ns_radio',radioMode?'1':'0');
_applyRadioBtn();
if(radioMode){
await fillRadioQueue();
}
}
async function fillRadioQueue(){
if(!radioMode)return;
try{
const r=await api('getRandomSongs','&size=20');
const newSongs=r.randomSongs?.song||[];
// Append to current queue so playback doesn't interrupt
queue.push(...newSongs);
renderQueue();
}catch{}
}
// Override onEnded to refill queue in radio mode
const _origOnEnded=window.onEnded;
async function loadStarred(){
const el=document.getElementById('starred-songs');el.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getStarred2');
const songs=(r.starred2?.song||[]).map(s=>({...s,_starred:true}));
el.innerHTML='';
if(!songs.length){el.innerHTML='<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>';return}
songs.forEach((s,i)=>el.appendChild(makeSongItem(s,i,songs)));
}catch(e){el.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
async function loadRandom(){
const el=document.getElementById('random-songs');el.innerHTML='<div class="spinner"></div>';
try{const r=await api('getRandomSongs','&size=50');const songs=r.randomSongs?.song||[];el.innerHTML='';songs.forEach((s,i)=>el.appendChild(makeSongItem(s,i,songs)))}
catch(e){el.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── LOCAL SEARCH ───
function onLocalSearch(val){
clearTimeout(searchTimer);
if(!val.trim()){navigate('home');return}
navigate('search');
searchTimer=setTimeout(()=>doLocalSearch(val),350);
}
async function doLocalSearch(q){
const el=document.getElementById('search-results');el.innerHTML='<div class="spinner"></div>';
try{
const r=await api('search3',`&query=${encodeURIComponent(q)}&songCount=30&albumCount=10&artistCount=6`);
const res=r.searchResult3||{};el.innerHTML='';
const artists=res.artist||[],albums=res.album||[],songs=res.song||[];
if(!artists.length&&!albums.length&&!songs.length){el.innerHTML='<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse</p></div>';return}
if(artists.length){const s=mkSection('Künstler');const g=document.createElement('div');g.className='scroll-row';artists.forEach(a=>{const c=makeCard(a.name,a.albumCount+' Alben',null,'🎤');c.onclick=()=>navigate('artist-detail',a);g.appendChild(c);getArtistImage(a.id).then(img=>{if(!img)return;const artEl=c.querySelector('.card-art');if(artEl)artEl.innerHTML=`<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'"><div class="play-overlay"><button class="play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button></div>`})});s.appendChild(g);el.appendChild(s)}
if(albums.length){const s=mkSection('Alben');const g=document.createElement('div');g.className='scroll-row';albums.forEach(a=>g.appendChild(makeAlbumCard(a)));s.appendChild(g);el.appendChild(s)}
if(songs.length){const s=mkSection('Titel');const g=document.createElement('div');g.className='scroll-row';songs.forEach((so,i)=>{const cv=coverUrl(so.coverArt,200);const c=makeCard(so.title,so.artist||'',cv,'🎵');c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();playQueue(songs,i)};c.onclick=()=>playQueue(songs,i);g.appendChild(c)});s.appendChild(g);el.appendChild(s)}
}catch(e){el.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
function mkSection(title){const d=document.createElement('div');d.className='search-section';d.innerHTML=`<div class="search-section-title">${title}</div>`;return d}
function toggleMobileSearch(){
const bar=document.getElementById('mobile-search-bar');
const btn=document.getElementById('mobile-search-btn');
const open=bar.style.display==='none'||bar.style.display==='';
bar.style.display=open?'block':'none';
btn.style.color=open?'var(--accent)':'var(--text2)';
if(open){setTimeout(()=>document.getElementById('mobile-search-input').focus(),50);}
else{closeMobileSearch();}
}
function closeMobileSearch(){
const bar=document.getElementById('mobile-search-bar');
const inp=document.getElementById('mobile-search-input');
const btn=document.getElementById('mobile-search-btn');
bar.style.display='none';
btn.style.color='var(--text2)';
inp.value='';
navigate('home');
}
let _mobileSearchTimer=null;
function onMobileSearch(val){
clearTimeout(_mobileSearchTimer);
if(!val.trim()){navigate('home');return;}
navigate('search');
_mobileSearchTimer=setTimeout(()=>doLocalSearch(val),300);
}
// ─── ITUNES API ───
const ITUNES='https://itunes.apple.com';
async function itunesSearch(query,entity='musicTrack',limit=25){
const r=await fetch(`${ITUNES}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=de`);
const d=await r.json();return d.results||[];
}
async function itunesLookupAlbum(collectionId){
const r=await fetch(`${ITUNES}/lookup?id=${collectionId}&entity=song`);
const d=await r.json();return(d.results||[]).filter(x=>x.wrapperType==='track');
}
async function itunesTopCharts(){
// Try Apple RSS (DE then US), then fall back to iTunes Search API
const urls=['https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json','https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json'];
for(const url of urls){try{const r=await fetch(url,{signal:AbortSignal.timeout(6000)});if(!r.ok)continue;const d=await r.json();if(d.feed?.results?.length)return d.feed.results;}catch{}}
try{
const r=await fetch('https://itunes.apple.com/search?term=new+music&media=music&entity=album&limit=50&country=de');
const d=await r.json();
return(d.results||[]).map(a=>({name:a.collectionName,artistName:a.artistName,artworkUrl:a.artworkUrl100?.replace('100x100','300x300'),artworkUrl100:a.artworkUrl100}));
}catch{return[]}
}
async function itunesTopSongs(){
const urls=['https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json','https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json'];
for(const url of urls){try{const r=await fetch(url,{signal:AbortSignal.timeout(6000)});if(!r.ok)continue;const d=await r.json();if(d.feed?.results?.length)return d.feed.results;}catch{}}
try{
const r=await fetch('https://itunes.apple.com/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de');
const d=await r.json();
return(d.results||[]).map(t=>({name:t.trackName,artistName:t.artistName,artworkUrl:t.artworkUrl100}));
}catch{return[]}
}
// ─── GLOBAL SEARCH ───
function onGlobalSearch(val){
clearTimeout(globalSearchTimer);
const el=document.getElementById('global-search-results');
if(!val.trim()){el.innerHTML='<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein</p></div>';return}
el.innerHTML='<div class="spinner"></div>';
globalSearchTimer=setTimeout(()=>doGlobalSearch(val),400);
}
async function doGlobalSearch(q){
const el=document.getElementById('global-search-results');
try{
const [tracks,albums,artists]=await Promise.all([
itunesSearch(q,'musicTrack',25),
itunesSearch(q,'album',10),
itunesSearch(q,'musicArtist',6)
]);
el.innerHTML='';
if(artists.length){
const s=mkSection('Künstler');
const row=document.createElement('div');row.className='scroll-row';
artists.forEach(a=>{
const c=makeCard(a.artistName,'iTunes Künstler',null,'🎤');
c.onclick=()=>loadItunesArtistResults(a.artistId,a.artistName);
row.appendChild(c);
// Lazy-load: grab artwork from their first album
itunesSearch(a.artistName,'album',1).then(res=>{
if(!res[0])return;
const imgUrl=res[0].artworkUrl100?.replace('100x100','300x300');
if(!imgUrl)return;
const artEl=c.querySelector('.card-art');
if(!artEl)return;
const img=document.createElement('img');
img.src=imgUrl; img.loading='lazy'; img.style.cssText='width:100%;height:100%;object-fit:cover;transition:transform .4s';
// Keep overlay
const overlay=artEl.querySelector('.play-overlay');
artEl.innerHTML=''; artEl.appendChild(img); if(overlay)artEl.appendChild(overlay);
img.onerror=()=>{ artEl.innerHTML='<div class="no-art">🎤</div>'; if(overlay)artEl.appendChild(overlay); };
c.addEventListener('mouseenter',()=>img.style.transform='scale(1.05)');
c.addEventListener('mouseleave',()=>img.style.transform='scale(1)');
}).catch(()=>{});
});
s.appendChild(row);el.appendChild(s);
}
if(albums.length){
const s=mkSection('Alben');
const row=document.createElement('div');row.className='scroll-row';
albums.forEach(a=>{
const art=a.artworkUrl100?.replace('100x100','300x300');
const c=makeCard(a.collectionName,a.artistName,art,'💿');
c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();loadItunesAlbum(a)};
c.querySelector('.dl-btn-overlay')?.remove();
// Add download overlay button
const dlOverlay=document.createElement('button');
dlOverlay.className='dl-btn-overlay';
dlOverlay.title='Auf Server laden';
dlOverlay.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
dlOverlay.onclick=(e)=>{e.stopPropagation();openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a})};
c.querySelector('.play-overlay').appendChild(dlOverlay);
c.onclick=()=>loadItunesAlbum(a);
row.appendChild(c);
});
s.appendChild(row);el.appendChild(s);
}
if(tracks.length){
const s=mkSection('Titel');
const list=document.createElement('div');list.className='song-list';
tracks.forEach((t,i)=>list.appendChild(makeGlobalTrackItem(t,i,tracks)));
s.appendChild(list);el.appendChild(s);
}
if(!artists.length&&!albums.length&&!tracks.length){
el.innerHTML='<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
}
}catch(e){el.innerHTML=`<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`}
}
async function loadItunesAlbum(album){
try{
const tracks=await itunesLookupAlbum(album.collectionId);
if(!tracks.length){toast('Keine Tracks gefunden');return}
// Show in a mini overlay list in discover page
const el=document.getElementById('global-search-results');
const s=document.createElement('div');s.className='search-section';
s.innerHTML=`<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="flex:1">
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
</div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div>
<div class="search-section-title">Tracks</div>`;
const list=document.createElement('div');list.className='song-list';
tracks.forEach((t,i)=>list.appendChild(makeGlobalTrackItem(t,i,tracks)));
s.appendChild(list);
el.innerHTML='';el.appendChild(s);
}catch(e){toast('Fehler: '+e.message)}
}
async function loadItunesArtistResults(artistId,artistName){
const el=document.getElementById('global-search-results');
el.innerHTML='<div class="spinner"></div>';
try{
const albums=await itunesSearch(artistName,'album',20);
const artistAlbums=albums.filter(a=>a.artistId===artistId||a.artistName===artistName);
el.innerHTML='';
const header=document.createElement('div');
header.innerHTML=`<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
<button class="back-btn" style="margin:0" onclick="onGlobalSearch(document.getElementById('global-search-input').value)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="font-size:20px;font-weight:800">${esc(artistName)}</div>
</div>
<div class="section-title" style="margin-bottom:12px">Alben</div>`;
el.appendChild(header);
const grid=document.createElement('div');grid.className='grid grid-4';
(artistAlbums.length?artistAlbums:albums).forEach(a=>{
const art=a.artworkUrl100?.replace('100x100','300x300');
const c=makeCard(a.collectionName,a.artistName,art,'💿');
c.onclick=()=>loadItunesAlbum(a);
grid.appendChild(c);
});
el.appendChild(grid);
}catch(e){el.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
function makeGlobalTrackItem(t,idx,list){
const el=document.createElement('div');el.className='gtrack-item';
const art=t.artworkUrl60||t.artworkUrl100||'';
el.innerHTML=`
<div class="gtrack-art">${art?`<img src="${art}" loading="lazy">`:'<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}</div>
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
</div>
<div class="gtrack-actions">
${t.previewUrl?`<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'&quot;')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`:''}
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'&quot;')})">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick=()=>{if(t.previewUrl)playPreview(t)};
return el;
}
// ─── NEW RELEASES ───
async function loadNewReleases(){
const albumEl=document.getElementById('nr-albums');const songEl=document.getElementById('nr-songs');
albumEl.innerHTML='<div class="spinner"></div>';songEl.innerHTML='';
try{
const [albums,songs]=await Promise.all([itunesTopCharts(),itunesTopSongs()]);
albumEl.innerHTML='';
albums.slice(0,30).forEach(a=>{
const art=a.artworkUrl100?.replace('100x100','300x300')||a.artworkUrl;
const c=makeCard(a.name,a.artistName,art,'💿');
c.onclick=()=>itunesSearch(a.name+' '+a.artistName,'album',5).then(res=>{if(res[0])loadItunesAlbum(res[0])});
albumEl.appendChild(c);
});
songs.slice(0,40).forEach((s,i)=>{
const itTrack={trackName:s.name,artistName:s.artistName,artworkUrl60:s.artworkUrl?.replace('100x100','60x60'),collectionName:''};
const el=document.createElement('div');el.className='gtrack-item';
el.innerHTML=`
<div class="gtrack-art">${itTrack.artworkUrl60?`<img src="${itTrack.artworkUrl60}">`:`<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>`}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(s.name)}</div>
<div class="gtrack-sub">${esc(s.artistName||'')}</div>
</div>
<div class="gtrack-actions">
<span style="font-size:11px;color:var(--text3);font-family:var(--mono)">#${i+1}</span>
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal({trackName:'${esc(s.name)}',artistName:'${esc(s.artistName||'')}',artworkUrl100:'${s.artworkUrl||''}',collectionName:''})">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick=()=>{itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res=>{if(res[0]&&res[0].previewUrl)playPreview(res[0])})}
songEl.appendChild(el);
});
}catch(e){albumEl.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── RECOMMENDATIONS (Last.fm + iTunes fallback) ───
async function loadRecommendations(){
const el=document.getElementById('reco-content');
const currentSong=queue[qIdx];
if(!currentSong){
el.innerHTML='<div class="empty-state"><div class="icon">🎵</div><p>Spiele zuerst Musik in deiner Bibliothek</p></div>';
return;
}
el.innerHTML='<div class="spinner"></div>';
try{
let html='';
const artist=currentSong.artist||'';
const title=currentSong.title||'';
html+=`<div style="padding:14px;background:var(--surface);border-radius:10px;margin-bottom:20px;display:flex;align-items:center;gap:12px">
<div style="font-size:28px">🎧</div>
<div><div style="font-size:12px;color:var(--text2)">Basierend auf</div><div style="font-size:15px;font-weight:700">${esc(title)}</div><div style="font-size:13px;color:var(--text2)">${esc(artist)}</div></div>
</div>`;
el.innerHTML=html+'<div class="spinner"></div>';
// Try Last.fm first
if(cfg.lastfmKey){
await loadLastfmRecommendations(el,artist,title,html);
} else {
await loadItunesFallbackRecommendations(el,artist,title,html);
el.querySelector('#reco-nolastfm') && null;
const note=document.createElement('div');
note.style.cssText='margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
note.innerHTML='💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
el.appendChild(note);
}
}catch(e){el.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
async function loadLastfmRecommendations(el,artist,title,html){
const BASE='https://ws.audioscrobbler.com/2.0';
const key=cfg.lastfmKey;
try{
const [simArtists,topTracks,simTracks]=await Promise.all([
fetch(`${BASE}/?method=artist.getSimilar&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=12`).then(r=>r.json()),
fetch(`${BASE}/?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
fetch(`${BASE}/?method=track.getSimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
]);
el.innerHTML=html;
const simArtistList=simArtists.similarartists?.artist||[];
const topTrackList=topTracks.toptracks?.track||[];
const simTrackList=simTracks.similartracks?.track||[];
if(simArtistList.length){
el.innerHTML+=`<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:24px">`;
el.innerHTML+=simArtistList.map(a=>`<button class="reco-artist-chip" onclick="searchSimilarArtist('${esc(a.name)}')">${esc(a.name)}</button>`).join('');
el.innerHTML+='</div>';
}
if(simTrackList.length){
const sec=document.createElement('div');sec.innerHTML=`<div class="section-title" style="margin-bottom:10px">Ähnliche Tracks</div>`;
const list=document.createElement('div');list.className='song-list';
simTrackList.slice(0,15).forEach(t=>{
const row=document.createElement('div');row.className='gtrack-item';
row.innerHTML=`<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||'')}</div></div>
<div class="gtrack-actions">
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(t.artist?.name||'')}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(t.artist?.name||'')}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
</div>`;
row.onclick=()=>searchAndPreview(t.name,t.artist?.name||'');
list.appendChild(row);
});
sec.appendChild(list);el.appendChild(sec);
}
if(topTrackList.length){
const sec=document.createElement('div');sec.style.marginTop='20px';sec.innerHTML=`<div class="section-title" style="margin-bottom:10px">Top Tracks von ${esc(artist)}</div>`;
const list=document.createElement('div');list.className='song-list';
topTrackList.slice(0,10).forEach(t=>{
const row=document.createElement('div');row.className='gtrack-item';
row.innerHTML=`<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||artist)}</div></div>
<div class="gtrack-actions">
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(artist)}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(artist)}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
</div>`;
row.onclick=()=>searchAndPreview(t.name,artist);
list.appendChild(row);
});
sec.appendChild(list);el.appendChild(sec);
}
}catch(e){await loadItunesFallbackRecommendations(el,artist,title,html)}
}
async function loadItunesFallbackRecommendations(el,artist,title,html){
const results=await itunesSearch(artist,'musicTrack',20);
el.innerHTML=html;
const sec=document.createElement('div');
sec.innerHTML=`<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
const list=document.createElement('div');list.className='song-list';
results.filter(t=>t.artistName===artist).slice(0,15).forEach(t=>list.appendChild(makeGlobalTrackItem(t,0,[t])));
if(!list.children.length)results.slice(0,15).forEach(t=>list.appendChild(makeGlobalTrackItem(t,0,[t])));
sec.appendChild(list);el.appendChild(sec);
}
async function searchSimilarArtist(artistName){
navigate('discover');
document.getElementById('global-search-input').value=artistName;
onGlobalSearch(artistName);
}
async function searchAndPreview(trackName,artistName){
try{const res=await itunesSearch(trackName+' '+artistName,'musicTrack',1);if(res[0]&&res[0].previewUrl)playPreview(res[0]);else toast('Kein Preview verfügbar')}
catch{toast('Suche fehlgeschlagen')}
}
// ─── PREVIEW PLAYER ───
let previewCurrent=null;
function playPreview(track){
if(!track.previewUrl){toast('Kein Preview verfügbar');return}
// Pause main player, remember if it was playing so we can resume later
const wasPlaying=!audio.paused;
if(wasPlaying) audio.pause();
previewCurrent=track;
previewCurrent._resumeMain=wasPlaying;
// Inherit current volume from main volume slider
previewAudio.volume=audio.volume;
previewAudio.src=track.previewUrl;
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%';
}
function stopPreview(resume){
const shouldResume = resume !== false && previewCurrent?._resumeMain;
previewAudio.pause();previewAudio.src='';
document.getElementById('preview-bar').classList.remove('visible');
document.getElementById('preview-fill').style.width='0%';
previewCurrent=null;
// Resume main player if it was playing before preview started
if(shouldResume && audio.src) audio.play();
}
function updatePreviewProgress(){
if(!previewAudio.duration)return;
document.getElementById('preview-fill').style.width=(previewAudio.currentTime/previewAudio.duration*100)+'%';
}
// ─── DOWNLOAD MODAL ───
function openDlModal(track){
if(!cfg.backendUrl){toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.');return}
downloadTarget=track;dlType='track';
document.querySelectorAll('.modal-opt').forEach(o=>o.classList.toggle('selected',o.dataset.type==='track'));
const info=document.getElementById('dl-modal-info');
const art=track.artworkUrl100||track.artworkUrl60||'';
info.innerHTML=`${art?`<div class="modal-art"><img src="${art}"></div>`:'<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
document.getElementById('dl-album-sub').textContent=track.collectionName?`"${track.collectionName}" herunterladen`:'Album herunterladen';
document.getElementById('download-modal').classList.add('open');
}
function closeDlModal(){document.getElementById('download-modal').classList.remove('open')}
function selectDlType(el,type){
dlType=type;
document.querySelectorAll('.modal-opt').forEach(o=>o.classList.remove('selected'));
el.classList.add('selected');
}
async function executeDownload(){
if(!downloadTarget||!cfg.backendUrl)return;
closeDlModal();
const jobId='job_'+Date.now();
const payload={
type:dlType,
artist:downloadTarget.artistName||'Unknown Artist',
title:downloadTarget.trackName||downloadTarget.collectionName||'Unknown',
album:downloadTarget.collectionName||'',
artworkUrl:downloadTarget.artworkUrl100||downloadTarget.artworkUrl60||'',
itunesId:downloadTarget.trackId||downloadTarget.collectionId||null,
collectionId:downloadTarget.collectionId||null
};
addJob(jobId,payload.title,payload.artist);
try{
const res=await fetch(`${cfg.backendUrl}/api/download`,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({...payload,jobId})
});
if(!res.ok)throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
}catch(e){
updateJob(jobId,'error','Verbindung zum Backend fehlgeschlagen');
toast('❌ Backend nicht erreichbar: '+e.message);
}
}
// ─── JOBS ───
function addJob(id,title,artist){
jobs[id]={title,artist,progress:0,status:'pending'};
renderJobs();
}
function updateJob(id,status,msg,progress){
if(!jobs[id])return;
jobs[id]={...jobs[id],status,msg:msg||'',progress:progress??jobs[id].progress};
renderJobs();
if(status==='done'){setTimeout(()=>{delete jobs[id];renderJobs()},5000)}
if(status==='error'){setTimeout(()=>{delete jobs[id];renderJobs()},8000)}
}
function renderJobs(){
const panel=document.getElementById('jobs-panel');
panel.innerHTML='';
Object.entries(jobs).forEach(([id,j])=>{
const c=document.createElement('div');c.className='job-card';
const isDone=j.status==='done',isErr=j.status==='error';
const statusText=isErr?'Fehler':isDone?'✓ Fertig! Bibliothek wird gescannt…':j.msg||'Wird heruntergeladen…';
c.innerHTML=`<div class="job-header">
<div class="job-title">${esc(j.title)}</div>
<div class="job-status">${esc(j.artist)}</div>
</div>
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
<div class="job-sub">${statusText}</div>`;
panel.appendChild(c);
});
}
async function pollJob(jobId){
if(!jobs[jobId])return;
try{
const res=await fetch(`${cfg.backendUrl}/api/status/${jobId}`);
const d=await res.json();
updateJob(jobId,d.status,d.message,d.progress);
if(d.status!=='done'&&d.status!=='error')setTimeout(()=>pollJob(jobId),1500);
else if(d.status==='done'){toast(`✅ ${jobs[jobId]?.title||'Track'} gespeichert!`)}
}catch{updateJob(jobId,'error','Status-Abfrage fehlgeschlagen')}
}
// ─── LOCAL DOWNLOAD (from Navidrome) ───
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}`);
}
function downloadCurrent(){if(qIdx>=0&&queue[qIdx])dlSongLocal(queue[qIdx])}
// ─── PLAYER ───
function playQueue(songs,startIdx){queue=songs.map(s=>({...s}));qIdx=startIdx;playCurrent();renderQueue();if(radioMode)fillRadioQueue();}
function playCurrent(){
if(qIdx<0||qIdx>=queue.length)return;
const s=queue[qIdx];
audio.src=streamUrl(s.id);audio.play();
updatePlayerUI(s);renderQueue();
}
function updatePlayerUI(s){
document.getElementById('player-title').textContent=s.title;
document.getElementById('player-artist-name').textContent=s.artist||'—';
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`;
updateMediaSession(s);
if(document.getElementById('page-recommendations').classList.contains('active'))loadRecommendations();
// Save last played to localStorage
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: 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
}));
} catch {}
// Sync NP overlay (direct DOM, no recursion)
_syncNpDirect(s);
}
function _syncNpDirect(s){
if(!npOpen||!s)return;
const cv=coverUrl(s.coverArt,400);
const cover=document.getElementById('np-cover');
const bg=document.getElementById('np-bg');
if(cover)cover.innerHTML=cv?`<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`:'<div class="no-art">♪</div>';
if(bg)bg.style.backgroundImage=cv?`url('${cv}')`:'none';
const t=document.getElementById('np-title');
if(t)t.textContent=s.title||'—';
const an=document.getElementById('np-artist-name');if(an)an.textContent=s.artist||'—';
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();
}
function updateMediaSession(s){
if(!navigator.mediaSession)return;
const artwork=s.coverArt?[{src:coverUrl(s.coverArt,96),sizes:'96x96',type:'image/jpeg'},{src:coverUrl(s.coverArt,300),sizes:'300x300',type:'image/jpeg'},{src:coverUrl(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=>{audio.currentTime=Math.max(0,audio.currentTime-(d.seekOffset||10))});
navigator.mediaSession.setActionHandler('seekforward',d=>{audio.currentTime=Math.min(audio.duration||0,audio.currentTime+(d.seekOffset||10))});
navigator.mediaSession.setActionHandler('seekto',d=>{if(d.seekTime!=null)audio.currentTime=d.seekTime});
}
let _msPosLast=0;
function syncMediaSessionPosition(){
if(!navigator.mediaSession?.setPositionState||!audio.duration||isNaN(audio.duration))return;
const now=Date.now();
if(now-_msPosLast<1000)return;
_msPosLast=now;
try{navigator.mediaSession.setPositionState({duration:audio.duration,playbackRate:audio.playbackRate||1,position:Math.min(audio.currentTime,audio.duration)});}catch{}
}
function togglePlay(){if(audio.src)audio.paused?audio.play():audio.pause()}
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"/>';
// Sync NP overlay play icon (no recursion - direct DOM update)
if(npOpen){
document.getElementById('np-play-icon').innerHTML=playing?'<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>':'<polygon points="5 3 19 12 5 21 5 3"/>';
const cover=document.getElementById('np-cover');
if(cover)cover.classList.toggle('playing',playing);
}
}
function prevSong(){if(audio.currentTime>3){audio.currentTime=0;return}if(qIdx>0){qIdx--;playCurrent()}}
function nextSong(){
if(shuffle){qIdx=Math.floor(Math.random()*queue.length)}
else if(qIdx<queue.length-1)qIdx++;
else if(repeat)qIdx=0;
else if(radioMode){fillRadioQueue().then(()=>{if(qIdx<queue.length-1){qIdx++;playCurrent()}});return;}
else return;
// Radio: refill when fewer than 5 songs remain in queue
if(radioMode&&queue.length-qIdx<5)fillRadioQueue();
playCurrent();
}
function onEnded(){if(repeat==='one'){audio.currentTime=0;audio.play();return}nextSong()}
function toggleShuffle(){
shuffle=!shuffle;
document.getElementById('btn-shuffle').classList.toggle('active',shuffle);
const nb=document.getElementById('np-shuffle-btn');if(nb)nb.classList.toggle('active',shuffle);
document.getElementById('badge-shuffle')?.classList.toggle('on',shuffle);
}
function toggleRepeat(){
repeat=repeat===false?'all':repeat==='all'?'one':false;
document.getElementById('btn-repeat').classList.toggle('active',repeat!==false);
document.getElementById('btn-repeat').classList.toggle('repeat-one',repeat==='one');
const nb=document.getElementById('np-repeat-btn');
if(nb){nb.classList.toggle('active',repeat!==false);nb.classList.toggle('repeat-one',repeat==='one');}
const badge=document.getElementById('badge-repeat');
if(badge){badge.classList.toggle('on',repeat!==false);badge.textContent=repeat==='one'?'🔂 Einmal':'🔁 Repeat';}
}
function expandArtistBio(){
const el=document.getElementById('artist-bio-text');
if(el&&window._artistBioFull)el.textContent=window._artistBioFull;
}
function updateProgress(){
if(!audio.duration||_scrubbing)return;
syncMediaSessionPosition();
const p=(audio.currentTime/audio.duration)*100;
document.getElementById('progress-fill').style.width=p+'%';
document.getElementById('time-cur').textContent=formatDur(audio.currentTime);
document.getElementById('time-total').textContent=formatDur(audio.duration);
// Sync NP overlay progress (no recursion - direct DOM update)
if(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(audio.currentTime);
if(tot)tot.textContent=formatDur(audio.duration);
}
}
function seekTo(e){const bar=document.getElementById('progress-bar');const rect=bar.getBoundingClientRect();audio.currentTime=((e.clientX-rect.left)/rect.width)*audio.duration}
let _scrubbing=false;
function initProgressScrub(){
_initBarScrub(
document.getElementById('progress-bar'),
document.getElementById('progress-fill'),
pos=>{
document.getElementById('time-cur').textContent=formatDur(pos*audio.duration);
}
);
_initBarScrub(
document.getElementById('np-bar'),
document.getElementById('np-fill'),
pos=>{
const el=document.getElementById('np-cur');
if(el)el.textContent=formatDur(pos*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){_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){_scrubbing=false;fill.style.transition='';if(audio.duration)audio.currentTime=pos*audio.duration;}
bar.addEventListener('touchstart',e=>{startScrub(getPos(e.touches[0].clientX));e.stopPropagation();},{passive:true});
bar.addEventListener('touchmove',e=>{if(_scrubbing)moveScrub(getPos(e.touches[0].clientX));},{passive:true});
bar.addEventListener('touchend',e=>{if(_scrubbing)endScrub(getPos(e.changedTouches[0].clientX));},{passive:true});
bar.addEventListener('mousedown',e=>{
startScrub(getPos(e.clientX));e.preventDefault();
const mm=e2=>{if(_scrubbing)moveScrub(getPos(e2.clientX));};
const mu=e2=>{if(_scrubbing)endScrub(getPos(e2.clientX));document.removeEventListener('mousemove',mm);document.removeEventListener('mouseup',mu);};
document.addEventListener('mousemove',mm);document.addEventListener('mouseup',mu);
});
}
function setVolume(v){audio.volume=v/100;previewAudio.volume=v/100}
// ─── STAR ───
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);
const svg=btn.querySelector('svg');
if(svg){svg.setAttribute('fill',song._starred?'currentColor':'none')}
}
if(queue[qIdx]?.id===song.id)updateStarBtn();
}catch(e){toast('Fehler: '+e.message)}
}
function starCurrent(){if(qIdx<0||!queue[qIdx])return;toggleStar(queue[qIdx],document.getElementById('btn-star-current'))}
function updateStarBtn(){
const s=queue[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)');
}
// ─── QUEUE ───
function renderQueue(){
const el=document.getElementById('queue-list');el.innerHTML='';
if(!queue.length){el.innerHTML='<div class="empty-state" style="padding:40px 16px"><div class="icon">🎵</div><p>Leer</p></div>';return}
queue.forEach((s,i)=>{
const item=document.createElement('div');item.className='queue-item'+(i===qIdx?' active':'');
item.innerHTML=`<div class="qi-idx">${i+1}</div><div class="qi-info"><div class="qi-title">${esc(s.title)}</div><div class="qi-artist">${esc(s.artist||'')}</div></div>`;
item.onclick=()=>{qIdx=i;playCurrent()};el.appendChild(item);
});
el.querySelector('.active')?.scrollIntoView({block:'nearest'});
}
let queueOpen=false;
function toggleQueue(){queueOpen=!queueOpen;document.getElementById('queue-panel').classList.toggle('open',queueOpen);if(queueOpen)renderQueue()}
// ─── SETTINGS ───
function openSettings(){
document.getElementById('s-url').value=cfg.url||'';
document.getElementById('s-user').value=cfg.user||'';
document.getElementById('s-pass').value='';
document.getElementById('s-lastfm').value=cfg.lastfmKey||'';
document.getElementById('s-backend').value=cfg.backendUrl||'';
document.getElementById('settings-modal').classList.add('open');
checkBackend();
}
function closeSettings(){document.getElementById('settings-modal').classList.remove('open')}
function saveSettings(){
const pass=document.getElementById('s-pass').value;
cfg.url=document.getElementById('s-url').value.trim().replace(/\/$/,'');
cfg.user=document.getElementById('s-user').value.trim();
if(pass)cfg.pass=pass;
cfg.lastfmKey=document.getElementById('s-lastfm').value.trim();
cfg.backendUrl=document.getElementById('s-backend').value.trim().replace(/\/$/,'');
localStorage.setItem('ns_cfg',JSON.stringify(cfg));
closeSettings();toast('✅ Gespeichert');
}
async function checkBackend(){
const dot=document.getElementById('backend-status-dot');
const txt=document.getElementById('backend-status-text');
const url=document.getElementById('s-backend').value.trim()||cfg.backendUrl;
if(!url){dot.className='status-dot';txt.textContent='Keine URL konfiguriert';return}
dot.className='status-dot';txt.textContent='Verbinde…';
try{
const r=await fetch(`${url}/api/health`,{signal:AbortSignal.timeout(4000)});
const d=await r.json();
dot.className='status-dot ok';txt.textContent=`Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`;
}catch(e){dot.className='status-dot err';txt.textContent='Nicht erreichbar: '+e.message}
}
// ─── CARD / ITEM MAKERS ───
function makeCard(title,sub,imgUrl,fallback='💿'){
const c=document.createElement('div');c.className='card';
c.innerHTML=`<div class="card-art">
${imgUrl?`<img src="${imgUrl}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>${fallback}</div>'">`:`<div class="no-art">${fallback}</div>`}
<div class="play-overlay"><button class="play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button></div>
</div>
<div class="card-info"><div class="card-title">${esc(title)}</div><div class="card-sub">${esc(sub||'')}</div></div>`;
return c;
}
function makeAlbumCard(a){
const cv=coverUrl(a.coverArt,200);
const c=makeCard(a.name,a.artist||'',cv,'💿');
c.querySelector('.play-btn').onclick=(e)=>{e.stopPropagation();quickPlayAlbum(a)};
c.onclick=()=>navigate('album-detail',a);
return c;
}
function makeSongItem(s,idx,list){
const el=document.createElement('div');
if(s.starred&&!s._starred)s._starred=true;
el.className='song-item'+(queue[qIdx]?.id===s.id?' playing':'');
const cv=coverUrl(s.coverArt,60);
el.innerHTML=`<div class="song-num">${idx+1}</div>
<div class="song-thumb">${cv?`<img src="${cv}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>♪</div>'">`:'<div class="no-art">♪</div>'}</div>
<div class="song-info">
<div class="song-title">${esc(s.title)}</div>
<div class="song-artist">
${s.artistId||s.artist?`<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.artist||'')}</span>`:''}
${s.albumId||s.album?`<span style="color:var(--text3)"> · </span><span class="song-link" onclick="event.stopPropagation();s_goAlbum(event,'${s.albumId||''}','${esc(s.album||'')}','${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.album||'')}</span>`:''}
</div>
</div>
<div class="song-meta">
<span class="song-dur">${formatDur(s.duration)}</span>
<div class="song-actions">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn" title="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
</div>
</div>`;
const btns=el.querySelectorAll('.icon-btn');
btns[0].onclick=(e)=>{e.stopPropagation();toggleStar(s,btns[0])};
btns[1].onclick=(e)=>{e.stopPropagation();dlSongLocal(s)};
el.onclick=()=>playQueue(list,idx);
el.addEventListener('contextmenu',e=>{e.preventDefault();showCtxMenu(e,s,list,idx)});
return el;
}
async function quickPlayAlbum(a){
try{const r=await api('getAlbum',`&id=${a.id}`);playQueue(r.album?.song||[],0)}
catch(e){toast('Fehler: '+e.message)}
}
// Song list: navigate to artist or album from song row
function s_goArtist(e,name,id){
if(!name)return;
navigate('artist-detail',{id:id||name,name});
}
function s_goAlbum(e,albumId,albumName,artist,artistId){
if(albumId){
navigate('album-detail',{id:albumId,name:albumName,artist,artistId:artistId||'',coverArt:''});
}
}
// ─── HOME ───
async function loadHomeData(){
loadAlbumRow('home-random','getAlbumList2','&type=random&size=20');
loadAlbumRow('home-newest','getAlbumList2','&type=newest&size=20');
loadAlbumRow('home-frequent','getAlbumList2','&type=frequent&size=20');
}
async function loadAlbumRow(id,method,extra){
const c=document.getElementById(id);
if(!c)return;
c.innerHTML='<div class="spinner"></div>';
try{
const r=await api(method,extra);
c.innerHTML='';
(r.albumList2?.album||[]).forEach(a=>c.appendChild(makeAlbumCard(a)));
}catch(e){c.innerHTML=`<div style="color:var(--text3);font-size:13px;padding:16px">${e.message}</div>`}
}
// ─── ARTISTS ───
const artistImgCache={};
async function getArtistImage(artistId){
if(artistImgCache[artistId]!==undefined)return artistImgCache[artistId];
try{
const r=await api('getArtistInfo2',`&id=${artistId}&count=0`);
const info=r.artistInfo2||{};
const img=info.largeImageUrl||info.mediumImageUrl||info.smallImageUrl||null;
const isBlank=img&&(img.includes('2a96cbd8b46e442fc41c2b86b821562f')||img.endsWith('/noimage/'));
artistImgCache[artistId]=isBlank?null:img;
return artistImgCache[artistId];
}catch{artistImgCache[artistId]=null;return null;}
}
async function loadArtists(){
const g=document.getElementById('artists-grid');
g.innerHTML='<div class="spinner"></div>';
try{
const r=await api('getArtists');
const all=(r.artists?.index||[]).flatMap(i=>i.artist||[]);
g.innerHTML='';
all.forEach(a=>{
const c=makeCard(a.name,a.albumCount+' Alben',null,'🎤');
c.onclick=()=>navigate('artist-detail',a);
g.appendChild(c);
getArtistImage(a.id).then(imgUrl=>{
if(!imgUrl)return;
const artEl=c.querySelector('.card-art');
if(!artEl)return;
const overlay=artEl.querySelector('.play-overlay');
const img=document.createElement('img');
img.src=imgUrl;img.loading='lazy';img.style.cssText='width:100%;height:100%;object-fit:cover;transition:transform .4s';
artEl.innerHTML='';artEl.appendChild(img);if(overlay)artEl.appendChild(overlay);
c.addEventListener('mouseenter',()=>img.style.transform='scale(1.05)');
c.addEventListener('mouseleave',()=>img.style.transform='scale(1)');
});
});
}catch(e){g.innerHTML=`<p style="color:var(--danger)">${e.message}</p>`}
}
// ─── PWA ───
function injectPWA(){
if (!('serviceWorker' in navigator)) return;
// Listen before registering so we never miss a message
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'SW_UPDATED') {
localStorage.setItem('sw-version', e.data.version);
showUpdateBanner(e.data.version);
}
// Version query response — covers the race where SW activated before this listener
if (e.data?.type === 'VERSION') {
const prev = localStorage.getItem('sw-version');
if (prev && prev !== e.data.version) showUpdateBanner(e.data.version);
localStorage.setItem('sw-version', e.data.version);
}
});
// updateViaCache:'none' prevents the browser HTTP cache from hiding SW file changes
navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).then(reg => {
reg.update().catch(() => {});
// Query active SW version right now to catch updates that happened before page JS ran
const ctrl = navigator.serviceWorker.controller;
if (ctrl) ctrl.postMessage({ type: 'GET_VERSION' });
}).catch(() => {});
}
function showUpdateBanner(version) {
if (document.getElementById('update-banner')) return;
const b = document.createElement('div');
b.id = 'update-banner';
b.className = 'update-banner';
b.innerHTML = `
<span class="update-banner-text">✦ Update verfügbar${version ? ' <span style="opacity:.55;font-size:10px">('+version+')</span>' : ''}</span>
<button class="update-banner-btn" onclick="window.location.reload()">Neu laden</button>
<button class="update-banner-close" onclick="document.getElementById('update-banner').remove()">✕</button>
`;
document.body.appendChild(b);
}
// ─── NP ACTION SHEET ─────────────────────────────────────────────
function openNpMenu() {
const s = queue[qIdx];
if (!s) return;
// Populate song header
const cv = coverUrl(s.coverArt, 80);
document.getElementById('np-sheet-song').innerHTML = `
<div class="thumb">${cv ? `<img src="${cv}">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div><div class="title">${esc(s.title)}</div><div class="sub">${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}</div></div>`;
// Actions
const items = [
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId)navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } },
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
];
document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
`<div class="np-sheet-item" data-idx="${i}">${item.icon}<span>${item.label}</span></div>`
).join('');
document.querySelectorAll('.np-sheet-item').forEach((el,i) => {
el.addEventListener('click', () => items[i].fn());
});
document.getElementById('np-sheet-overlay').classList.add('open');
document.getElementById('np-sheet').classList.add('open');
}
function closeNpMenu() {
document.getElementById('np-sheet-overlay').classList.remove('open');
document.getElementById('np-sheet').classList.remove('open');
}
// ─── PLAYER BAR clicks ───────────────────────────────────────────
function playerBarGoAlbum() {
const s = queue[qIdx];
if (!s) return;
if (npOpen) closeNowPlaying();
if (s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''});
}
function playerBarGoArtist() {
const s = queue[qIdx];
if (!s || !s.artist) return;
if (npOpen) closeNowPlaying();
navigate('artist-detail', {id:s.artistId||s.artist, name:s.artist});
}
// ─── HELPERS ───
function formatDur(s){if(!s)return'0:00';const m=Math.floor(s/60),sec=Math.floor(s%60);return`${m}:${sec.toString().padStart(2,'0')}`}
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;const c=document.getElementById('toast-container');c.appendChild(t);setTimeout(()=>t.remove(),3000)}
function openSidebar(){document.getElementById('sidebar').classList.add('mobile-open');document.getElementById('mobile-overlay').classList.add('active')}
function closeSidebar(){document.getElementById('sidebar').classList.remove('mobile-open');document.getElementById('mobile-overlay').classList.remove('active')}
function switchNavMode(mode){
const lib=document.getElementById('bnav-library');
const disc=document.getElementById('bnav-discover');
if(!lib||!disc)return;
lib.style.display=mode==='library'?'flex':'none';
disc.style.display=mode==='discover'?'flex':'none';
document.getElementById('bnav-tab-library')?.classList.toggle('active',mode==='library');
document.getElementById('bnav-tab-discover')?.classList.toggle('active',mode==='discover');
}
// ─── CONTEXT MENU ───────────────────────────────────────────────
let cachedPlaylists = [];
function showCtxMenu(e, song, list, idx) {
const menu = document.getElementById('ctx-menu');
menu.innerHTML = '';
menu.style.display = 'block';
const items = [
{ icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) },
{ icon: '+', label: 'Als Nächstes einfügen', fn: () => {
queue.splice(qIdx+1, 0, {...song});
renderQueue(); toast('In Warteschlange eingefügt');
}},
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
queue.push({...song}); renderQueue(); toast('Zur Queue hinzugefügt');
}},
{ sep: true },
{ icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) },
{ icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => {
const starBtn = menu._songEl?.querySelector('.icon-btn');
// toggle via API directly
api(song._starred ? 'unstar' : 'star', `&id=${song.id}`)
.then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); })
.catch(err => toast('Fehler: ' + err.message));
}},
{ sep: true },
{ icon: 'ℹ', label: 'Song-Info', fn: () => showSongInfo(song) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
];
items.forEach(item => {
if (item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; }
const el = document.createElement('div');
el.className = 'ctx-item';
el.innerHTML = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
el.onclick = () => { hideCtxMenu(); item.fn(); };
menu.appendChild(el);
});
// Position: keep inside viewport
const vw = window.innerWidth, vh = window.innerHeight;
let x = e.clientX, y = e.clientY;
menu.style.left = '-9999px'; menu.style.top = '-9999px'; menu.style.display = 'block';
const w = menu.offsetWidth, h = menu.offsetHeight;
if (x + w > vw - 8) x = vw - w - 8;
if (y + h > vh - 8) y = vh - h - 8;
menu.style.left = x + 'px'; menu.style.top = y + 'px';
}
function hideCtxMenu() {
const m = document.getElementById('ctx-menu');
m.style.display = 'none'; m.innerHTML = '';
}
document.addEventListener('click', e => {
if (!document.getElementById('ctx-menu').contains(e.target)) hideCtxMenu();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') hideCtxMenu(); });
// ─── PLAYLIST MODAL (Add to / Create) ────────────────────────────
let plModalSongs = [];
async function openPlModal(songs) {
plModalSongs = songs;
document.getElementById('pl-modal-title').textContent =
songs.length === 1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
document.getElementById('pl-new-name').value = '';
const list = document.getElementById('pl-list');
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
document.getElementById('pl-modal').classList.add('open');
try {
const r = await api('getPlaylists');
cachedPlaylists = r.playlists?.playlist || [];
list.innerHTML = '';
if (!cachedPlaylists.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
return;
}
cachedPlaylists.forEach(pl => {
const row = document.createElement('div');
row.className = 'pl-row';
row.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
list.appendChild(row);
});
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
}
function closePlModal() {
document.getElementById('pl-modal').classList.remove('open');
plModalSongs = [];
}
async function addSongsToPlaylist(plId, plName) {
closePlModal();
try {
// Subsonic API: updatePlaylist with songIdToAdd params
const ids = plModalSongs.map(s => `&songIdToAdd=${s.id}`).join('');
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
toast(`✅ ${plModalSongs.length === 1 ? plModalSongs[0].title : plModalSongs.length + ' Songs'} → ${plName}`);
} catch(e) { toast('Fehler: ' + e.message); }
}
async function createAndAddPlaylist() {
const name = document.getElementById('pl-new-name').value.trim();
if (!name) { document.getElementById('pl-new-name').focus(); return; }
closePlModal();
try {
const ids = plModalSongs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
toast(`✅ Playlist "${name}" erstellt`);
// Refresh playlist list if on that page
if (document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
} catch(e) { toast('Fehler: ' + e.message); }
}
async function createPlaylistPrompt() {
document.getElementById('pl-new-name').value = '';
plModalSongs = [];
document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen';
document.getElementById('pl-list').innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
document.getElementById('pl-modal').classList.add('open');
}
// click outside closes pl-modal
document.addEventListener('click', e => {
const m = document.getElementById('pl-modal');
if (m.classList.contains('open') && e.target === m) closePlModal();
});
// ─── SONG INFO MODAL ─────────────────────────────────────────────
function showSongInfo(s) {
const cv = coverUrl(s.coverArt, 200);
document.getElementById('info-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div>';
document.getElementById('info-top-title').textContent = s.title || '—';
document.getElementById('info-top-sub').textContent = (s.artist || '—') + (s.album ? ' · ' + s.album : '');
const fields = [
['Titel', s.title],
['Künstler', s.artist],
['Album', s.album],
['Jahr', s.year],
['Track #', s.track],
['Disc #', s.discNumber],
['Genre', s.genre],
['Dauer', formatDur(s.duration)],
['Bitrate', s.bitRate ? s.bitRate + ' kbps' : null],
['Format', s.suffix?.toUpperCase()],
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10 + ' MB' : null],
['Pfad', s.path],
['Plays', s.playCount],
].filter(([, v]) => v != null && v !== '' && v !== undefined);
document.getElementById('info-rows').innerHTML = fields.map(([l,v]) =>
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
).join('');
document.getElementById('info-modal').classList.add('open');
}
function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); }
document.addEventListener('click', e => {
const m = document.getElementById('info-modal');
if (m.classList.contains('open') && e.target === m) closeInfoModal();
});
// ─── NOW PLAYING OVERLAY ─────────────────────────────────────────
let npOpen = false;
function openNowPlaying() {
npOpen = true;
const overlay = document.getElementById('np-overlay');
overlay.classList.add('open');
if (npQueueMode) switchNpTab('player');
syncNowPlaying();
// Attach swipe-down once
if (!overlay._swipeAttached) {
overlay._swipeAttached = true;
let startY=0, startT=0, dragging=false;
const handle = document.getElementById('np-drag-handle');
const inner = document.getElementById('np-inner');
function onStart(clientY) {
startY = clientY; startT = Date.now(); dragging = true;
overlay.style.transition = 'none';
}
function onMove(clientY) {
if (!dragging) return;
const dy = Math.max(0, clientY - startY);
overlay.style.transform = `translateY(${dy}px)`;
}
function onEnd(clientY) {
if (!dragging) return;
dragging = false;
overlay.style.transition = '';
const dy = clientY - startY;
const dt = Date.now() - startT;
const fast = dt < 250 && dy > 50;
const far = dy > window.innerHeight * 0.3;
if (fast || far) {
closeNowPlaying();
} else {
overlay.style.transform = '';
}
}
// Touch
overlay.addEventListener('touchstart', e => {
// Only start drag from handle OR top 80px of overlay
const rect = overlay.getBoundingClientRect();
const ty = e.touches[0].clientY - rect.top;
if (ty < 100) onStart(e.touches[0].clientY);
}, {passive:true});
overlay.addEventListener('touchmove', e => {
onMove(e.touches[0].clientY);
}, {passive:true});
overlay.addEventListener('touchend', e => onEnd(e.changedTouches[0].clientY), {passive:true});
// Mouse (desktop)
handle.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); });
document.addEventListener('mousemove', e => onMove(e.clientY));
document.addEventListener('mouseup', e => onEnd(e.clientY));
}
}
function closeNowPlaying() {
npOpen = false;
const overlay = document.getElementById('np-overlay');
overlay.style.transform = '';
overlay.classList.remove('open');
}
function syncNowPlaying() {
if (!npOpen) return;
const s = queue[qIdx];
if (!s) return;
_syncNpDirect(s);
const shuffleBtn=document.getElementById('np-shuffle-btn');
const repeatBtn=document.getElementById('np-repeat-btn');
const volSlider=document.getElementById('np-vol-slider');
if(shuffleBtn)shuffleBtn.classList.toggle('active', shuffle);
if(repeatBtn)repeatBtn.classList.toggle('active', repeat !== false);
if(volSlider)volSlider.value = Math.round(audio.volume * 100);
// play icon & cover pulse
const playing=!audio.paused;
const npIcon=document.getElementById('np-play-icon');
if(npIcon)npIcon.innerHTML=playing?'<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>':'<polygon points="5 3 19 12 5 21 5 3"/>';
const cover=document.getElementById('np-cover');
if(cover)cover.classList.toggle('playing', playing);
}
function syncNpStar() {
if (!npOpen) return;
const s = queue[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)');
}
function npToggleStar() {
const s = queue[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));
}
function npSeek(e){}
let npQueueMode = false;
function switchNpTab(tab) {
npQueueMode = tab === 'queue';
document.getElementById('np-overlay').classList.toggle('queue-mode', npQueueMode);
document.getElementById('np-tab-player').classList.toggle('active', !npQueueMode);
document.getElementById('np-tab-queue').classList.toggle('active', npQueueMode);
if (npQueueMode) renderNpQueue();
}
function renderNpQueue() {
const el = document.getElementById('np-queue-list');
if (!el) return;
el.innerHTML = '';
if (!queue.length) {
el.innerHTML = '<div style="text-align:center;padding:40px;color:rgba(255,255,255,.3)">Warteschlange leer</div>';
return;
}
queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'np-queue-item' + (i === qIdx ? ' nq-active' : '');
const cv = coverUrl(s.coverArt, 60);
item.innerHTML = `<div class="nqi-num">${i + 1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist || '')}</div></div>`;
item.onclick = () => { qIdx = i; playCurrent(); switchNpTab('player'); };
el.appendChild(item);
});
el.querySelector('.nq-active')?.scrollIntoView({ block: 'nearest' });
}
function npGoArtist() {
const s = queue[qIdx];
if (!s) return;
closeNowPlaying();
if (s.artistId) navigate('artist-detail', { id: s.artistId, name: s.artist });
}
function npGoAlbum() {
const s = queue[qIdx];
if (!s || !s.albumId) return;
closeNowPlaying();
navigate('album-detail', { id: s.albumId, name: s.album||'', artist: s.artist||'', artistId: s.artistId||'', coverArt: s.coverArt||'' });
}
// Hook into existing audio events to keep NP in sync