diff --git a/src/components.js b/src/components.js
new file mode 100644
index 0000000..abf0e5f
--- /dev/null
+++ b/src/components.js
@@ -0,0 +1,86 @@
+import { S } from './state.js';
+import { esc, formatDur } from './utils.js';
+import { coverUrl } from './api.js';
+// Circular imports — safe (only used inside event handlers after module init)
+import { playQueue, dlSongLocal, toggleStar, playPreview } from './player.js';
+import { showCtxMenu, openDlModal } from './ui.js';
+import { navigate, quickPlayAlbum } from './views.js';
+
+// ─── GENERIC CARD ───
+export function makeCard(title, sub, imgUrl, fallback='💿') {
+ const c = document.createElement('div');
+ c.className = 'card';
+ c.innerHTML = `
+ ${imgUrl ? `

` : `
${fallback}
`}
+
+
+ ${esc(title)}
${esc(sub||'')}
`;
+ 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;
+}
+
+// ─── SONG LIST ITEM (library) ───
+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 = `${idx+1}
+ ${cv ? `

` : '
♪
'}
+
+
${esc(s.title)}
+
+ ${s.artistId||s.artist ? `${esc(s.artist||'')}` : ''}
+ ${s.albumId||s.album ? ` · ${esc(s.album||'')}` : ''}
+
+
+ `;
+ 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;
+}
+
+// ─── GLOBAL TRACK ITEM (iTunes / Spotify results) ───
+export function makeGlobalTrackItem(t, idx, list) {
+ const el = document.createElement('div');
+ el.className = 'gtrack-item';
+ const art = t.artworkUrl60 || t.artworkUrl100 || '';
+ el.innerHTML = `
+ ${art ? `

` : '
🎵
'}
+
+
${esc(t.trackName||t.collectionName||'—')}${t.trackExplicitness==='explicit'?'E':''}
+
${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}
+
+
+ ${t.previewUrl ? `
` : ''}
+
+
`;
+ el.onclick = () => { if(t.previewUrl) playPreview(t); };
+ return el;
+}
+
+// ─── SEARCH SECTION HEADER ───
+export function mkSection(title) {
+ const d = document.createElement('div');
+ d.className = 'search-section';
+ d.innerHTML = `${title}
`;
+ return d;
+}
diff --git a/src/main.js b/src/main.js
index eea0010..d8582f9 100644
--- a/src/main.js
+++ b/src/main.js
@@ -14,11 +14,15 @@ import {
navigate, _navGoBack, _navLoad, loadHomeData, onLocalSearch, onMobileSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio,
- loadRandom, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
+ loadRandom,
} from './views.js';
+import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js';
import {
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
- openNpMenu, closeNpMenu, openSettings, closeSettings, saveSettings,
+ openNpMenu, closeNpMenu,
+} from './nowplaying.js';
+import {
+ openSettings, closeSettings, saveSettings,
checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist,
createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal,
diff --git a/src/nowplaying.js b/src/nowplaying.js
new file mode 100644
index 0000000..4d8786b
--- /dev/null
+++ b/src/nowplaying.js
@@ -0,0 +1,255 @@
+import { S } from './state.js';
+import { esc } from './utils.js';
+import { coverUrl } from './api.js';
+// Circular imports — safe (only used inside function bodies)
+import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar } from './player.js';
+import { navigate } from './views.js';
+import { openPlModal, showSongInfo } from './ui.js';
+
+// ─── NOW PLAYING OVERLAY ───
+export function openNowPlaying() {
+ S.npOpen = true;
+ const overlay = document.getElementById('np-overlay');
+ overlay.classList.add('open');
+ if(S.npQueueMode) switchNpTab('player');
+ syncNowPlaying();
+ if(!overlay._swipeAttached) {
+ overlay._swipeAttached = true;
+ let startY=0, startT=0, dragging=false;
+ const handle = document.getElementById('np-drag-handle');
+ 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, dt=Date.now()-startT;
+ if(dt<250&&dy>50 || dy>window.innerHeight*0.3) closeNowPlaying();
+ else overlay.style.transform='';
+ }
+ overlay.addEventListener('touchstart', e => {
+ const rect=overlay.getBoundingClientRect();
+ if(e.touches[0].clientY-rect.top < 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});
+ handle.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); });
+ document.addEventListener('mousemove', e => onMove(e.clientY));
+ document.addEventListener('mouseup', e => onEnd(e.clientY));
+ }
+}
+
+export function closeNowPlaying() {
+ S.npOpen = false;
+ const overlay = document.getElementById('np-overlay');
+ overlay.style.transform = '';
+ overlay.classList.remove('open');
+}
+
+export function syncNowPlaying() {
+ if(!S.npOpen) return;
+ const s = S.queue[S.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', S.shuffle);
+ if(repeatBtn) repeatBtn.classList.toggle('active', S.repeat !== false);
+ if(volSlider) volSlider.value = Math.round(S.audio.volume * 100);
+ const playing = !S.audio.paused;
+ const npIcon = document.getElementById('np-play-icon');
+ if(npIcon) npIcon.innerHTML = playing
+ ? ''
+ : '';
+ const cover = document.getElementById('np-cover');
+ if(cover) cover.classList.toggle('playing', playing);
+}
+
+export function switchNpTab(tab) {
+ S.npQueueMode = tab === 'queue';
+ S.npLyricsMode = tab === 'lyrics';
+ const overlay = document.getElementById('np-overlay');
+ overlay.classList.toggle('queue-mode', S.npQueueMode);
+ overlay.classList.toggle('lyrics-mode', S.npLyricsMode);
+ document.getElementById('np-tab-player').classList.toggle('active', tab === 'player');
+ document.getElementById('np-tab-lyrics').classList.toggle('active', S.npLyricsMode);
+ document.getElementById('np-tab-queue').classList.toggle('active', S.npQueueMode);
+ if(S.npQueueMode) renderNpQueue();
+ if(S.npLyricsMode) { const s = S.queue[S.qIdx]; if(s) _loadNpLyrics(s.id); }
+}
+
+// ─── LYRICS ───
+let _lyricsLines = [];
+let _lyricsSongId = null;
+
+const LYRICS_LOOKAHEAD_MS = 200;
+
+function _parseLrc(text) {
+ let offsetMs = 0;
+ const lines = [];
+ for(const raw of text.split('\n')) {
+ const offM = raw.match(/^\[offset:\s*([+-]?\d+)\s*\]/i);
+ if(offM) { offsetMs = parseInt(offM[1]); continue; }
+ const m = raw.match(/^\[(\d+):(\d+(?:\.\d+)?)\](.*)/);
+ if(m) lines.push({ start: Math.round((parseInt(m[1])*60 + parseFloat(m[2]))*1000), value: m[3].trim() });
+ }
+ if(offsetMs) lines.forEach(l => l.start = Math.max(0, l.start + offsetMs));
+ return lines.sort((a,b) => a.start - b.start).filter(l => l.value);
+}
+
+function _renderSyncedLines(content, lines) {
+ const top = document.createElement('div');
+ top.style.cssText = 'height:40vh;flex-shrink:0';
+ content.appendChild(top);
+ _lyricsLines = lines.map(line => {
+ const el = document.createElement('div');
+ el.className = 'lyrics-line';
+ el.textContent = line.value;
+ el.onclick = () => { S.audio.currentTime = line.start / 1000; };
+ content.appendChild(el);
+ return { start: line.start, el };
+ });
+ const bot = document.createElement('div');
+ bot.style.cssText = 'height:40vh;flex-shrink:0';
+ content.appendChild(bot);
+ updateLyricsHighlight(S.audio.currentTime * 1000);
+}
+
+async function _loadNpLyrics(songId) {
+ const content = document.getElementById('np-lyrics-content');
+ if(!content) return;
+ if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
+ if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000);
+ return;
+ }
+ _lyricsSongId = songId;
+ _lyricsLines = [];
+ content.innerHTML = 'Lade Lyrics…
';
+
+ const s = S.queue[S.qIdx];
+ const { getLyricsBySongId } = await import('./api.js');
+
+ // 1. Try Navidrome structured lyrics
+ const lyrics = await getLyricsBySongId(songId);
+ const track = lyrics[0];
+ if(track?.synced && track.line?.length) {
+ content.innerHTML = '';
+ _renderSyncedLines(content, track.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
+ return;
+ }
+
+ // 2. Fallback: fetch synced lyrics from LRCLIB
+ let lrcText = null;
+ try {
+ const p = new URLSearchParams({ artist_name: s?.artist||'', track_name: s?.title||'' });
+ if(s?.album) p.set('album_name', s.album);
+ const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(8000) });
+ if(r.ok) { const d = await r.json(); lrcText = d.syncedLyrics || null; }
+ } catch {}
+
+ content.innerHTML = '';
+ if(lrcText) {
+ const parsed = _parseLrc(lrcText);
+ if(parsed.length) { _renderSyncedLines(content, parsed); return; }
+ }
+
+ // 3. Plain text fallback
+ const plain = track?.line?.length
+ ? track.line.map(l => l.value).join('\n')
+ : null;
+ if(plain) {
+ const el = document.createElement('div');
+ el.className = 'lyrics-plain';
+ el.textContent = plain;
+ content.appendChild(el);
+ } else {
+ const el = document.createElement('div');
+ el.className = 'lyrics-none';
+ el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
+ el.textContent = 'Keine Lyrics verfügbar';
+ content.appendChild(el);
+ }
+}
+
+export function updateLyricsHighlight(currentMs) {
+ if(!_lyricsLines.length || !S.npLyricsMode) return;
+ const adjusted = currentMs + LYRICS_LOOKAHEAD_MS;
+ let activeIdx = -1;
+ for(let i = 0; i < _lyricsLines.length; i++) {
+ if(_lyricsLines[i].start <= adjusted) activeIdx = i;
+ else break;
+ }
+ _lyricsLines.forEach((line, i) => {
+ line.el.classList.toggle('active', i === activeIdx);
+ line.el.classList.toggle('past', i < activeIdx);
+ });
+ if(activeIdx >= 0) _lyricsLines[activeIdx].el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+}
+
+export function onNpSongChange(songId) {
+ _lyricsSongId = null;
+ _lyricsLines = [];
+ const content = document.getElementById('np-lyrics-content');
+ if(content) content.innerHTML = '';
+ if(S.npLyricsMode) _loadNpLyrics(songId);
+}
+
+// ─── NP QUEUE ───
+export function renderNpQueue() {
+ const el = document.getElementById('np-queue-list');
+ if(!el) return;
+ el.innerHTML = '';
+ if(!S.queue.length) {
+ el.innerHTML = 'Warteschlange leer
';
+ return;
+ }
+ S.queue.forEach((s, i) => {
+ const item = document.createElement('div');
+ item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
+ const cv = coverUrl(s.coverArt, 60);
+ item.innerHTML = `${i+1}
${cv ? `

` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`;
+ item.onclick = () => { S.qIdx=i; playCurrent(); switchNpTab('player'); };
+ el.appendChild(item);
+ });
+ el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
+}
+
+// ─── NAVIGATION FROM NP ───
+export function npGoArtist() {
+ const s = S.queue[S.qIdx]; if(!s) return;
+ closeNowPlaying();
+ if(s.artistId) navigate('artist-detail', {id:s.artistId, name:s.artist});
+}
+
+export function npGoAlbum() {
+ const s = S.queue[S.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||''});
+}
+
+// ─── NP ACTION SHEET ───
+export function openNpMenu() {
+ const s = S.queue[S.qIdx]; if(!s) return;
+ const cv = coverUrl(s.coverArt, 80);
+ document.getElementById('np-sheet-song').innerHTML = `
+ ${cv ? `

` : '
🎵
'}
+ ${esc(s.title)}
${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}
`;
+ const items = [
+ { icon: '', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
+ { icon: '', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
+ { icon: '', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
+ { icon: '', 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: '', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
+ { icon: '', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
+ ];
+ document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
+ `${item.icon}${item.label}
`
+ ).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');
+}
+
+export function closeNpMenu() {
+ document.getElementById('np-sheet-overlay').classList.remove('open');
+ document.getElementById('np-sheet').classList.remove('open');
+}
diff --git a/src/player.js b/src/player.js
index d522f34..2e6d928 100644
--- a/src/player.js
+++ b/src/player.js
@@ -3,7 +3,7 @@ import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from './views.js';
-import { updateLyricsHighlight, onNpSongChange } from './ui.js';
+import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
// ─── PLAYBACK ───
export function playQueue(songs, startIdx) {
diff --git a/src/spotify.js b/src/spotify.js
new file mode 100644
index 0000000..6cfa91d
--- /dev/null
+++ b/src/spotify.js
@@ -0,0 +1,187 @@
+import { S } from './state.js';
+import { esc, toast } from './utils.js';
+// Circular imports — safe (only used inside function bodies)
+import { makeGlobalTrackItem } from './components.js';
+import { openDlModal } from './ui.js';
+
+// ─── SPOTIFY IMPORTER PAGE ───
+export function loadSpotifyImporter() {
+ if (!document.getElementById('spotify-link-input')?.value) {
+ const hint = document.getElementById('spotify-importer-hint');
+ const preview = document.getElementById('spotify-preview');
+ if (hint) hint.style.display = '';
+ if (preview) preview.style.display = 'none';
+ }
+}
+
+export function clearSpotifyPreview() {
+ if (!document.getElementById('spotify-link-input')?.value) {
+ const hint = document.getElementById('spotify-importer-hint');
+ const preview = document.getElementById('spotify-preview');
+ if (hint) hint.style.display = '';
+ if (preview) preview.style.display = 'none';
+ }
+}
+
+export async function resolveSpotifyLink() {
+ const url = document.getElementById('spotify-link-input')?.value.trim();
+ if (!url) return;
+ if (!S.cfg.backendUrl) { toast('⚠ Backend URL in Einstellungen konfigurieren'); return; }
+
+ const hint = document.getElementById('spotify-importer-hint');
+ const preview = document.getElementById('spotify-preview');
+ const inner = document.getElementById('spotify-preview-inner');
+ const section = document.getElementById('spotify-tracks-section');
+
+ if (hint) hint.style.display = 'none';
+ preview.style.display = '';
+ section.style.display = 'none';
+ inner.innerHTML = '';
+
+ try {
+ const res = await fetch(`${S.cfg.backendUrl}/api/spotify/resolve`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url }),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ window._spotifyResolved = data;
+ _renderSpotifyPreview(data);
+ } catch(e) {
+ inner.innerHTML = `${esc(e.message)}
`;
+ }
+}
+
+export function spotifyDlAll(cbId, nameId) {
+ const data = window._spotifyResolved;
+ if (!data) return;
+ window.executeSpotifyDownload({
+ type: 'playlist',
+ title: document.getElementById(nameId)?.value || data.name,
+ artist: data.artist,
+ artworkUrl: data.artworkUrl,
+ tracks: data.tracks,
+ createPlaylist: document.getElementById(cbId)?.checked || false,
+ playlistName: document.getElementById(nameId)?.value || data.name,
+ });
+}
+
+function _renderSpotifyPreview(data) {
+ const inner = document.getElementById('spotify-preview-inner');
+ const section = document.getElementById('spotify-tracks-section');
+ const list = document.getElementById('spotify-tracks-list');
+ const titleEl = document.getElementById('spotify-tracks-title');
+
+ const typeLabel = { track: 'Spotify Track', album: 'Spotify Album', playlist: 'Spotify Playlist' }[data.type] || 'Spotify';
+ const art = (data.artworkUrl || '').replace(/\/\d+x\d+bb/, '/300x300bb').replace('100x100', '300x300');
+ const count = data.tracks?.length || 0;
+ const uid = Date.now();
+ const plCbId = `sp-cb-${uid}`, plNmId = `sp-pln-${uid}`;
+
+ let actionsHtml = '';
+ if (data.type === 'track' && count > 0) {
+ actionsHtml = `
+
+
+ ${data.tracks[0]?.collectionName ? `` : ''}
+
`;
+ } else if (data.type === 'album') {
+ actionsHtml = `
+ `;
+ } else if (data.type === 'playlist') {
+ actionsHtml = `
+ `;
+ }
+
+ const artSize = data.type === 'playlist' ? '80px' : '110px';
+ const artRadius = data.type === 'playlist' ? '8px' : '10px';
+ const artHtml = art
+ ? `
`
+ : '';
+
+ inner.innerHTML = `
+
+
+
+
+
${esc(data.name)}
+
${esc(data.artist)}${count > 1 ? ` · ${count} Tracks` : ''}
+ ${actionsHtml}
+
+
`;
+
+ titleEl.textContent = `${count} Tracks`;
+ list.innerHTML = '';
+ (data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
+ section.style.display = '';
+
+ // Lazy-load correct per-track artwork via iTunes in the background
+ if ((data.type === 'playlist' || data.type === 'album') && count > 0 && S.cfg.backendUrl) {
+ _loadSpotifyTrackArtwork(data.tracks, list);
+ }
+}
+
+async function _loadSpotifyTrackArtwork(tracks, listEl) {
+ try {
+ const payload = tracks.map((t, i) => ({ idx: i, trackName: t.trackName, artistName: t.artistName }));
+ const res = await fetch(`${S.cfg.backendUrl}/api/spotify/track-artwork`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tracks: payload }),
+ });
+ if (!res.ok) return;
+ const { results } = await res.json();
+ const artEls = listEl.querySelectorAll('.gtrack-art');
+ for (const r of results) {
+ if (!r.artworkUrl || r.idx == null) continue;
+ const el = artEls[r.idx];
+ if (!el) continue;
+ const img = document.createElement('img');
+ img.src = r.artworkUrl;
+ img.loading = 'lazy';
+ el.innerHTML = '';
+ el.appendChild(img);
+ // Update resolved data so downloads use the correct artwork
+ if (window._spotifyResolved?.tracks?.[r.idx]) {
+ window._spotifyResolved.tracks[r.idx].artworkUrl100 = r.artworkUrl;
+ }
+ }
+ } catch { /* artwork loading is non-critical */ }
+}
diff --git a/src/ui.js b/src/ui.js
index bbe1682..aac218b 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -460,247 +460,3 @@ export async function editPlRemoveCover() {
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
-// ─── NOW PLAYING OVERLAY ───
-export function openNowPlaying() {
- S.npOpen = true;
- const overlay = document.getElementById('np-overlay');
- overlay.classList.add('open');
- if(S.npQueueMode) switchNpTab('player');
- syncNowPlaying();
- if(!overlay._swipeAttached) {
- overlay._swipeAttached = true;
- let startY=0, startT=0, dragging=false;
- const handle = document.getElementById('np-drag-handle');
- 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, dt=Date.now()-startT;
- if(dt<250&&dy>50 || dy>window.innerHeight*0.3) closeNowPlaying();
- else overlay.style.transform='';
- }
- overlay.addEventListener('touchstart', e => {
- const rect=overlay.getBoundingClientRect();
- if(e.touches[0].clientY-rect.top < 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});
- handle.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); });
- document.addEventListener('mousemove', e => onMove(e.clientY));
- document.addEventListener('mouseup', e => onEnd(e.clientY));
- }
-}
-
-export function closeNowPlaying() {
- S.npOpen = false;
- const overlay = document.getElementById('np-overlay');
- overlay.style.transform = '';
- overlay.classList.remove('open');
-}
-
-export function syncNowPlaying() {
- if(!S.npOpen) return;
- const s = S.queue[S.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', S.shuffle);
- if(repeatBtn) repeatBtn.classList.toggle('active', S.repeat !== false);
- if(volSlider) volSlider.value = Math.round(S.audio.volume * 100);
- const playing = !S.audio.paused;
- const npIcon = document.getElementById('np-play-icon');
- if(npIcon) npIcon.innerHTML = playing
- ? ''
- : '';
- const cover = document.getElementById('np-cover');
- if(cover) cover.classList.toggle('playing', playing);
-}
-
-export function switchNpTab(tab) {
- S.npQueueMode = tab === 'queue';
- S.npLyricsMode = tab === 'lyrics';
- const overlay = document.getElementById('np-overlay');
- overlay.classList.toggle('queue-mode', S.npQueueMode);
- overlay.classList.toggle('lyrics-mode', S.npLyricsMode);
- document.getElementById('np-tab-player').classList.toggle('active', tab === 'player');
- document.getElementById('np-tab-lyrics').classList.toggle('active', S.npLyricsMode);
- document.getElementById('np-tab-queue').classList.toggle('active', S.npQueueMode);
- if(S.npQueueMode) renderNpQueue();
- if(S.npLyricsMode) { const s = S.queue[S.qIdx]; if(s) _loadNpLyrics(s.id); }
-}
-
-// ─── LYRICS ───
-let _lyricsLines = [];
-let _lyricsSongId = null;
-
-const LYRICS_LOOKAHEAD_MS = 200; // highlight line this many ms before its timestamp
-
-function _parseLrc(text) {
- let offsetMs = 0;
- const lines = [];
- for(const raw of text.split('\n')) {
- const offM = raw.match(/^\[offset:\s*([+-]?\d+)\s*\]/i);
- if(offM) { offsetMs = parseInt(offM[1]); continue; }
- const m = raw.match(/^\[(\d+):(\d+(?:\.\d+)?)\](.*)/);
- if(m) lines.push({ start: Math.round((parseInt(m[1])*60 + parseFloat(m[2]))*1000), value: m[3].trim() });
- }
- // [offset:N] shifts all timestamps by N ms (positive = lines appear later)
- if(offsetMs) lines.forEach(l => l.start = Math.max(0, l.start + offsetMs));
- return lines.sort((a,b) => a.start - b.start).filter(l => l.value);
-}
-
-function _renderSyncedLines(content, lines) {
- const top = document.createElement('div');
- top.style.cssText = 'height:40vh;flex-shrink:0';
- content.appendChild(top);
- _lyricsLines = lines.map(line => {
- const el = document.createElement('div');
- el.className = 'lyrics-line';
- el.textContent = line.value;
- el.onclick = () => { S.audio.currentTime = line.start / 1000; };
- content.appendChild(el);
- return { start: line.start, el };
- });
- const bot = document.createElement('div');
- bot.style.cssText = 'height:40vh;flex-shrink:0';
- content.appendChild(bot);
- updateLyricsHighlight(S.audio.currentTime * 1000);
-}
-
-async function _loadNpLyrics(songId) {
- const content = document.getElementById('np-lyrics-content');
- if(!content) return;
- if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
- if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000);
- return;
- }
- _lyricsSongId = songId;
- _lyricsLines = [];
- content.innerHTML = 'Lade Lyrics…
';
-
- const s = S.queue[S.qIdx];
- const { getLyricsBySongId } = await import('./api.js');
-
- // 1. Try Navidrome structured lyrics (may already be synced if .lrc sidecar scanned)
- const lyrics = await getLyricsBySongId(songId);
- const track = lyrics[0];
- if(track?.synced && track.line?.length) {
- content.innerHTML = '';
- _renderSyncedLines(content, track.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
- return;
- }
-
- // 2. Fallback: fetch synced lyrics directly from LRCLIB
- let lrcText = null;
- try {
- const p = new URLSearchParams({ artist_name: s?.artist||'', track_name: s?.title||'' });
- if(s?.album) p.set('album_name', s.album);
- const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(8000) });
- if(r.ok) { const d = await r.json(); lrcText = d.syncedLyrics || null; }
- } catch {}
-
- content.innerHTML = '';
- if(lrcText) {
- const parsed = _parseLrc(lrcText);
- if(parsed.length) { _renderSyncedLines(content, parsed); return; }
- }
-
- // 3. Plain text fallback (Navidrome unsynced or LRCLIB plainLyrics)
- const plain = track?.line?.length
- ? track.line.map(l => l.value).join('\n')
- : null;
- if(plain) {
- const el = document.createElement('div');
- el.className = 'lyrics-plain';
- el.textContent = plain;
- content.appendChild(el);
- } else {
- const el = document.createElement('div');
- el.className = 'lyrics-none';
- el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
- el.textContent = 'Keine Lyrics verfügbar';
- content.appendChild(el);
- }
-}
-
-export function updateLyricsHighlight(currentMs) {
- if(!_lyricsLines.length || !S.npLyricsMode) return;
- const adjusted = currentMs + LYRICS_LOOKAHEAD_MS;
- let activeIdx = -1;
- for(let i = 0; i < _lyricsLines.length; i++) {
- if(_lyricsLines[i].start <= adjusted) activeIdx = i;
- else break;
- }
- _lyricsLines.forEach((line, i) => {
- line.el.classList.toggle('active', i === activeIdx);
- line.el.classList.toggle('past', i < activeIdx);
- });
- if(activeIdx >= 0) _lyricsLines[activeIdx].el.scrollIntoView({ behavior: 'smooth', block: 'center' });
-}
-
-export function onNpSongChange(songId) {
- _lyricsSongId = null;
- _lyricsLines = [];
- const content = document.getElementById('np-lyrics-content');
- if(content) content.innerHTML = '';
- if(S.npLyricsMode) _loadNpLyrics(songId);
-}
-
-export function renderNpQueue() {
- const el = document.getElementById('np-queue-list');
- if(!el) return;
- el.innerHTML = '';
- if(!S.queue.length) {
- el.innerHTML = 'Warteschlange leer
';
- return;
- }
- S.queue.forEach((s, i) => {
- const item = document.createElement('div');
- item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
- const cv = coverUrl(s.coverArt, 60);
- item.innerHTML = `${i+1}
${cv ? `

` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`;
- item.onclick = () => { S.qIdx=i; playCurrent(); switchNpTab('player'); };
- el.appendChild(item);
- });
- el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
-}
-
-export function npGoArtist() {
- const s = S.queue[S.qIdx]; if(!s) return;
- closeNowPlaying();
- if(s.artistId) navigate('artist-detail', {id:s.artistId, name:s.artist});
-}
-export function npGoAlbum() {
- const s = S.queue[S.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||''});
-}
-
-// ─── NP ACTION SHEET ───
-export function openNpMenu() {
- const s = S.queue[S.qIdx]; if(!s) return;
- const cv = coverUrl(s.coverArt, 80);
- document.getElementById('np-sheet-song').innerHTML = `
- ${cv ? `

` : '
🎵
'}
- ${esc(s.title)}
${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}
`;
- const items = [
- { icon: '', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
- { icon: '', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
- { icon: '', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
- { icon: '', 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: '', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
- { icon: '', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
- ];
- document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
- `${item.icon}${item.label}
`
- ).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');
-}
-export function closeNpMenu() {
- document.getElementById('np-sheet-overlay').classList.remove('open');
- document.getElementById('np-sheet').classList.remove('open');
-}
diff --git a/src/views.js b/src/views.js
index ebbc999..a4bf0df 100644
--- a/src/views.js
+++ b/src/views.js
@@ -2,8 +2,11 @@ import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, 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';
+import { playQueue, dlSongLocal, playPreview } from './player.js';
+import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from './ui.js';
+import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
+import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
+import { loadSpotifyImporter } from './spotify.js';
// ─── NAVIGATION HISTORY ───
export function _navSave() {
@@ -74,82 +77,6 @@ export function _navGoBack() {
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 = `
- ${imgUrl ? `

` : `
${fallback}
`}
-
-
- ${esc(title)}
${esc(sub||'')}
`;
- 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 = `${idx+1}
- ${cv ? `

` : '
♪
'}
-
-
${esc(s.title)}
-
- ${s.artistId||s.artist ? `${esc(s.artist||'')}` : ''}
- ${s.albumId||s.album ? ` · ${esc(s.album||'')}` : ''}
-
-
- `;
- 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 = `
- ${art ? `

` : '
🎵
'}
-
-
${esc(t.trackName||t.collectionName||'—')}${t.trackExplicitness==='explicit'?'E':''}
-
${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}
-
-
- ${t.previewUrl ? `
` : ''}
-
-
`;
- el.onclick = () => { if(t.previewUrl) playPreview(t); };
- return el;
-}
-
-function mkSection(title) {
- const d = document.createElement('div');
- d.className = 'search-section';
- d.innerHTML = `${title}
`;
- return d;
-}
-
// ─── ARTIST IMAGE ───
export async function getArtistImage(artistId) {
if(S.artistImgCache[artistId] !== undefined) return S.artistImgCache[artistId];
@@ -894,189 +821,6 @@ async function loadItunesFallbackRecommendations(el, artist, title, html) {
sec.appendChild(list); el.appendChild(sec);
}
-// ─── SPOTIFY IMPORTER ───
-export function loadSpotifyImporter() {
- if (!document.getElementById('spotify-link-input')?.value) {
- const hint = document.getElementById('spotify-importer-hint');
- const preview = document.getElementById('spotify-preview');
- if (hint) hint.style.display = '';
- if (preview) preview.style.display = 'none';
- }
-}
-
-export function clearSpotifyPreview() {
- if (!document.getElementById('spotify-link-input')?.value) {
- const hint = document.getElementById('spotify-importer-hint');
- const preview = document.getElementById('spotify-preview');
- if (hint) hint.style.display = '';
- if (preview) preview.style.display = 'none';
- }
-}
-
-export async function resolveSpotifyLink() {
- const url = document.getElementById('spotify-link-input')?.value.trim();
- if (!url) return;
- if (!S.cfg.backendUrl) { toast('⚠ Backend URL in Einstellungen konfigurieren'); return; }
-
- const hint = document.getElementById('spotify-importer-hint');
- const preview = document.getElementById('spotify-preview');
- const inner = document.getElementById('spotify-preview-inner');
- const section = document.getElementById('spotify-tracks-section');
-
- if (hint) hint.style.display = 'none';
- preview.style.display = '';
- section.style.display = 'none';
- inner.innerHTML = '';
-
- try {
- const res = await fetch(`${S.cfg.backendUrl}/api/spotify/resolve`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url }),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err.error || `HTTP ${res.status}`);
- }
- const data = await res.json();
- window._spotifyResolved = data;
- _renderSpotifyPreview(data);
- } catch(e) {
- inner.innerHTML = `${esc(e.message)}
`;
- }
-}
-
-export function spotifyDlAll(cbId, nameId) {
- const data = window._spotifyResolved;
- if (!data) return;
- // Import executeSpotifyDownload from ui.js at call time (avoid circular at module level)
- window.executeSpotifyDownload({
- type: 'playlist',
- title: document.getElementById(nameId)?.value || data.name,
- artist: data.artist,
- artworkUrl: data.artworkUrl,
- tracks: data.tracks,
- createPlaylist: document.getElementById(cbId)?.checked || false,
- playlistName: document.getElementById(nameId)?.value || data.name,
- });
-}
-
-function _renderSpotifyPreview(data) {
- const inner = document.getElementById('spotify-preview-inner');
- const section = document.getElementById('spotify-tracks-section');
- const list = document.getElementById('spotify-tracks-list');
- const titleEl = document.getElementById('spotify-tracks-title');
-
- const typeLabel = { track: 'Spotify Track', album: 'Spotify Album', playlist: 'Spotify Playlist' }[data.type] || 'Spotify';
- const art = (data.artworkUrl || '').replace(/\/\d+x\d+bb/, '/300x300bb').replace('100x100', '300x300');
- const count = data.tracks?.length || 0;
- const uid = Date.now();
- const plCbId = `sp-cb-${uid}`, plNmId = `sp-pln-${uid}`;
-
- let actionsHtml = '';
- if (data.type === 'track' && count > 0) {
- actionsHtml = `
-
-
- ${data.tracks[0]?.collectionName ? `` : ''}
-
`;
- } else if (data.type === 'album') {
- actionsHtml = `
- `;
- } else if (data.type === 'playlist') {
- actionsHtml = `
- `;
- }
-
- const artSize = data.type === 'playlist' ? '80px' : '110px';
- const artRadius = data.type === 'playlist' ? '8px' : '10px';
- const artHtml = art
- ? `
`
- : '';
-
- inner.innerHTML = `
-
-
-
-
-
${esc(data.name)}
-
${esc(data.artist)}${count > 1 ? ` · ${count} Tracks` : ''}
- ${actionsHtml}
-
-
`;
-
- titleEl.textContent = `${count} Tracks`;
- list.innerHTML = '';
- (data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
- section.style.display = '';
-
- // Lazy-load correct per-track artwork via iTunes in the background
- if ((data.type === 'playlist' || data.type === 'album') && count > 0 && S.cfg.backendUrl) {
- _loadSpotifyTrackArtwork(data.tracks, list);
- }
-}
-
-async function _loadSpotifyTrackArtwork(tracks, listEl) {
- try {
- const payload = tracks.map((t, i) => ({ idx: i, trackName: t.trackName, artistName: t.artistName }));
- const res = await fetch(`${S.cfg.backendUrl}/api/spotify/track-artwork`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ tracks: payload }),
- });
- if (!res.ok) return;
- const { results } = await res.json();
- const artEls = listEl.querySelectorAll('.gtrack-art');
- for (const r of results) {
- if (!r.artworkUrl || r.idx == null) continue;
- const el = artEls[r.idx];
- if (!el) continue;
- const img = document.createElement('img');
- img.src = r.artworkUrl;
- img.loading = 'lazy';
- el.innerHTML = '';
- el.appendChild(img);
- // Update resolved data so downloads use the correct artwork
- if (window._spotifyResolved?.tracks?.[r.idx]) {
- window._spotifyResolved.tracks[r.idx].artworkUrl100 = r.artworkUrl;
- }
- }
- } catch { /* artwork loading is non-critical */ }
-}
-
// ─── HELPERS ───
export function expandArtistBio() {
const el = document.getElementById('artist-bio-text');