Migrate to Vite + ES module architecture

Split monolithic app.js (~1850 lines) into maintainable ES modules:
- src/state.js  — shared mutable state (S object)
- src/utils.js  — md5, esc, formatDur, toast
- src/api.js    — Subsonic API, Navidrome REST, iTunes API
- src/player.js — playback, transport, queue, media session, preview
- src/views.js  — navigation, page loaders, card makers, search
- src/ui.js     — all modals, overlays, sidebar, settings
- src/pwa.js    — service worker registration + update banner
- src/main.js   — app init, connect, window globals

Updated Dockerfile to multi-stage (Node build → nginx serve).
Static assets (sw.js, manifest.json, icon.svg) moved to public/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-26 13:01:34 +02:00
1 parent ce53c48835
commit 9aded56713
18 files changed
+3134 -2

No files matched your search

+767
View File
@@ -0,0 +1,767 @@
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl } from './api.js';
import { itunesSearch, itunesLookupAlbum, itunesTopCharts, itunesTopSongs } from './api.js';
// Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, playPreview } from './player.js';
import { showCtxMenu, openEditPlModal, openPlModal, openDlModal, closeSidebar, switchNavMode, closeNowPlaying, closeNpMenu } from './ui.js';
// ─── NAVIGATION HISTORY ───
export function _navSave() {
try { sessionStorage.setItem('ns_nav', JSON.stringify({stack: S._navStack, cursor: S._navCursor})); } catch {}
}
export function _navLoad() {
try {
const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null');
if(s && Array.isArray(s.stack) && s.stack.length) {
S._navStack = s.stack;
S._navCursor = s.cursor;
return true;
}
} catch {}
return false;
}
export function navigate(page, data=null) {
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');
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();
if(!S._navJumping) {
S._navStack = S._navStack.slice(0, S._navCursor + 1);
const safe = data ? JSON.parse(JSON.stringify(data)) : null;
const last = S._navStack[S._navCursor];
if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) {
S._navStack.push({ page, data: safe });
}
S._navCursor = S._navStack.length - 1;
_navSave();
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
}
}
export function _navGoBack() {
if(document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; }
if(S.npOpen) { closeNowPlaying(); return; }
if(S._navCursor > 0) {
S._navCursor--;
const entry = S._navStack[S._navCursor];
_navSave();
S._navJumping = true;
navigate(entry.page, entry.data);
S._navJumping = false;
}
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
}
// ─── CARD MAKERS ───
export 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;
}
export 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;
}
export function makeSongItem(s, idx, list) {
const el = document.createElement('div');
if(s.starred && !s._starred) s._starred = true;
el.className = 'song-item' + (S.queue[S.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;
}
export function makeGlobalTrackItem(t, idx, list) {
const el = document.createElement('div');
el.className = 'gtrack-item';
const art = t.artworkUrl60 || t.artworkUrl100 || '';
el.innerHTML = `
<div class="gtrack-art">${art ? `<img src="${art}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}</div>
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
</div>
<div class="gtrack-actions">
${t.previewUrl ? `<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'&quot;')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` : ''}
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'&quot;')})">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick = () => { if(t.previewUrl) playPreview(t); };
return el;
}
function mkSection(title) {
const d = document.createElement('div');
d.className = 'search-section';
d.innerHTML = `<div class="search-section-title">${title}</div>`;
return d;
}
// ─── ARTIST IMAGE ───
export async function getArtistImage(artistId) {
if(S.artistImgCache[artistId] !== undefined) return S.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/'));
S.artistImgCache[artistId] = isBlank ? null : img;
return S.artistImgCache[artistId];
} catch { S.artistImgCache[artistId] = null; return null; }
}
// ─── HOME ───
export 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');
}
export 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 ───
export 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>`; }
}
// ─── ALBUMS ───
export 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>`; }
}
// ─── ARTIST DETAIL ───
export 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))';
['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>';
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`)
]);
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%)';
}
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>`;
}
const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null;
if(info?.biography) {
const bioEl = document.getElementById('artist-bio-section');
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>`;
}
}
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>`;
}
}
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);
}
}
}
// ─── ALBUM DETAIL ───
export 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 ───
export 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>`; }
}
export async function quickPlayPlaylist(pl) {
try { const r = await api('getPlaylist', `&id=${pl.id}`); playQueue(r.playlist?.entry || [], 0); }
catch(e) { toast('Fehler: ' + e.message); }
}
export async function loadPlaylistDetail(pl) {
S._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(S._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 || {};
S._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>`; }
}
export async function quickPlayAlbum(a) {
try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); }
catch(e) { toast('Fehler: ' + e.message); }
}
// ─── STARRED / RANDOM ───
export 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>`; }
}
export 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 ───
export function onLocalSearch(val) {
clearTimeout(S.searchTimer);
if(!val.trim()) { navigate('home'); return; }
navigate('search');
S.searchTimer = setTimeout(() => doLocalSearch(val), 350);
}
export 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) return;
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>`; }
}
// ─── MOBILE SEARCH ───
export function onMobileSearch(val) {
clearTimeout(S._mobileSearchTimer);
if(!val.trim()) { navigate('home'); return; }
navigate('search');
S._mobileSearchTimer = setTimeout(() => doLocalSearch(val), 300);
}
// ─── GLOBAL (ITUNES) SEARCH ───
export function onGlobalSearch(val) {
clearTimeout(S.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>';
S.globalSearchTimer = setTimeout(() => doGlobalSearch(val), 400);
}
export 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);
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';
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();
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>`; }
}
export async function loadItunesAlbum(album) {
try {
const tracks = await itunesLookupAlbum(album.collectionId);
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
const el = document.getElementById('global-search-results');
const s = document.createElement('div'); s.className = 'search-section';
s.innerHTML = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="flex:1">
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
</div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div>
<div class="search-section-title">Tracks</div>`;
const list = document.createElement('div'); list.className = 'song-list';
tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks)));
s.appendChild(list);
el.innerHTML = ''; el.appendChild(s);
} catch(e) { toast('Fehler: ' + e.message); }
}
export 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>`; }
}
// ─── NEW RELEASES ───
export 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 el = document.createElement('div'); el.className = 'gtrack-item';
const art = s.artworkUrl?.replace('100x100','60x60');
el.innerHTML = `
<div class="gtrack-art">${art ? `<img src="${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(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]?.previewUrl) playPreview(res[0]); }); };
songEl.appendChild(el);
});
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── RECOMMENDATIONS ───
export async function loadRecommendations() {
const el = document.getElementById('reco-content');
const currentSong = S.queue[S.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 {
const artist = currentSong.artist || '';
const title = currentSong.title || '';
let 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>';
if(S.cfg.lastfmKey) {
await loadLastfmRecommendations(el, artist, title, html);
} else {
await loadItunesFallbackRecommendations(el, artist, title, html);
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 = S.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 { 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);
}
// ─── HELPERS ───
export function expandArtistBio() {
const el = document.getElementById('artist-bio-text');
if(el && window._artistBioFull) el.textContent = window._artistBioFull;
}
export function searchSimilarArtist(artistName) {
navigate('discover');
document.getElementById('global-search-input').value = artistName;
onGlobalSearch(artistName);
}
export async function searchAndPreview(trackName, artistName) {
try {
const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1);
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
} catch { toast('Suche fehlgeschlagen'); }
}
export function s_goArtist(e, name, id) {
if(!name) return;
navigate('artist-detail', {id: id||name, name});
}
export function s_goAlbum(e, albumId, albumName, artist, artistId) {
if(albumId) navigate('album-detail', {id:albumId, name:albumName, artist, artistId:artistId||'', coverArt:''});
}
export function playerBarGoAlbum() {
const s = S.queue[S.qIdx];
if(!s) return;
if(S.npOpen) closeNowPlaying();
if(s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''});
}
export function playerBarGoArtist() {
const s = S.queue[S.qIdx];
if(!s || !s.artist) return;
if(S.npOpen) closeNowPlaying();
navigate('artist-detail', {id: s.artistId||s.artist, name: s.artist});
}