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||'')}` : ''} +
+
+
+ ${formatDur(s.duration)} +
+ + +
+
`; + 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 = ` +
+
+ ${artHtml} +
🎵
+
+
+
+ + ${typeLabel} +
+
${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||'')}` : ''} -
-
-
- ${formatDur(s.duration)} -
- - -
-
`; - 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 = ` -
-
- ${artHtml} -
🎵
-
-
-
- - ${typeLabel} -
-
${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');