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>
1857 lines
95 KiB
JavaScript
1857 lines
95 KiB
JavaScript
// ─── 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,'"')}})">⬇ 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,'"')})"><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,'"')})">
|
||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||
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
|
||
|
||
|
||
|