refactor: split views.js and ui.js into focused modules
Extract 3 new files from oversized source files: - src/components.js (~86 lines): makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection — reusable DOM builders - src/spotify.js (~187 lines): loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, and private render/artwork helpers - src/nowplaying.js (~255 lines): openNowPlaying, closeNowPlaying, syncNowPlaying, switchNpTab, full lyrics engine, renderNpQueue, npGoArtist/Album, openNpMenu/closeNpMenu views.js: 1121 → 864 lines ui.js: 706 → 462 lines Update imports in main.js, player.js, views.js accordingly. Build verified: 15 modules, no errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bdf3c7cbc
commit
16b7faee2b
7 files changed
+540
-508
No files matched your search
@@ -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 = `<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;
|
||||
}
|
||||
|
||||
// ─── 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 = `<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;
|
||||
}
|
||||
|
||||
// ─── 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 = `
|
||||
<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||'—')}${t.trackExplicitness==='explicit'?'<span class="explicit-badge">E</span>':''}</div>
|
||||
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
|
||||
</div>
|
||||
<div class="gtrack-actions">
|
||||
${t.previewUrl ? `<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'"')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` : ''}
|
||||
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'"')})">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
el.onclick = () => { if(t.previewUrl) playPreview(t); };
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─── SEARCH SECTION HEADER ───
|
||||
export function mkSection(title) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'search-section';
|
||||
d.innerHTML = `<div class="search-section-title">${title}</div>`;
|
||||
return d;
|
||||
}
|
||||
+6
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
const cover = document.getElementById('np-cover');
|
||||
if(cover) cover.classList.toggle('playing', playing);
|
||||
}
|
||||
|
||||
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 = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
|
||||
|
||||
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 = '<div style="text-align:center;padding:40px;color:rgba(255,255,255,.3)">Warteschlange leer</div>';
|
||||
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 = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
|
||||
item.onclick = () => { 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 = `
|
||||
<div class="thumb">${cv ? `<img src="${cv}">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
||||
<div><div class="title">${esc(s.title)}</div><div class="sub">${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}</div></div>`;
|
||||
const items = [
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId) navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
|
||||
];
|
||||
document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
|
||||
`<div class="np-sheet-item" data-idx="${i}">${item.icon}<span>${item.label}</span></div>`
|
||||
).join('');
|
||||
document.querySelectorAll('.np-sheet-item').forEach((el,i) => el.addEventListener('click', () => items[i].fn()));
|
||||
document.getElementById('np-sheet-overlay').classList.add('open');
|
||||
document.getElementById('np-sheet').classList.add('open');
|
||||
}
|
||||
|
||||
export function closeNpMenu() {
|
||||
document.getElementById('np-sheet-overlay').classList.remove('open');
|
||||
document.getElementById('np-sheet').classList.remove('open');
|
||||
}
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+187
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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 = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:2px">
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600"
|
||||
onclick="executeSpotifyDownload({type:'track',title:window._spotifyResolved.tracks[0].trackName,artist:window._spotifyResolved.tracks[0].artistName,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
|
||||
⬇ Track laden
|
||||
</button>
|
||||
${data.tracks[0]?.collectionName ? `<button class="btn-outline" style="font-size:12px;padding:9px 14px"
|
||||
onclick="openDlModal({artistName:window._spotifyResolved.tracks[0].artistName,trackName:window._spotifyResolved.tracks[0].collectionName,collectionName:window._spotifyResolved.tracks[0].collectionName,artworkUrl100:window._spotifyResolved.tracks[0].artworkUrl100||window._spotifyResolved.artworkUrl||'',isAlbum:true})">
|
||||
💿 ${esc(data.tracks[0].collectionName)}
|
||||
</button>` : ''}
|
||||
</div>`;
|
||||
} else if (data.type === 'album') {
|
||||
actionsHtml = `
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
|
||||
onclick="executeSpotifyDownload({type:'playlist',title:window._spotifyResolved.name,artist:window._spotifyResolved.artist,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
|
||||
⬇ Album laden (${count} Tracks)
|
||||
</button>`;
|
||||
} else if (data.type === 'playlist') {
|
||||
actionsHtml = `
|
||||
<div style="margin-top:6px">
|
||||
<label id="${plCbId}_label" style="display:inline-flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;margin-bottom:10px;background:var(--surface2);border:1px solid var(--border2);border-radius:20px;padding:6px 12px;transition:border-color .2s" onclick="void(0)">
|
||||
<input type="checkbox" id="${plCbId}" style="width:14px;height:14px;accent-color:#1DB954;cursor:pointer" onchange="
|
||||
document.getElementById('${plCbId}_wrap').style.display=this.checked?'':'none';
|
||||
document.getElementById('${plCbId}_label').style.borderColor=this.checked?'#1DB954':'';
|
||||
document.getElementById('${plCbId}_label').style.color=this.checked?'#1DB954':'';
|
||||
">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
Als Navidrome-Playlist speichern
|
||||
</label>
|
||||
<div id="${plCbId}_wrap" style="display:none;margin-bottom:10px">
|
||||
<input type="text" id="${plNmId}" value="${esc(data.name)}" placeholder="Playlist-Name…"
|
||||
style="background:var(--surface2);border:1px solid var(--border2);border-radius:8px;padding:8px 12px;font-size:13px;width:100%;box-sizing:border-box;color:var(--text);outline:none">
|
||||
</div>
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600"
|
||||
onclick="spotifyDlAll('${plCbId}','${plNmId}')">
|
||||
⬇ Alle ${count} Tracks laden
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const artSize = data.type === 'playlist' ? '80px' : '110px';
|
||||
const artRadius = data.type === 'playlist' ? '8px' : '10px';
|
||||
const artHtml = art
|
||||
? `<img src="${art}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
||||
: '';
|
||||
|
||||
inner.innerHTML = `
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;background:var(--surface);border-radius:14px;padding:18px 16px;margin-bottom:4px;border:1px solid var(--border);border-top:3px solid #1DB954">
|
||||
<div style="width:${artSize};height:${artSize};border-radius:${artRadius};overflow:hidden;flex-shrink:0;background:var(--surface2);position:relative">
|
||||
${artHtml}
|
||||
<div style="width:100%;height:100%;display:${art?'none':'flex'};align-items:center;justify-content:center;font-size:32px;position:absolute;top:0;left:0">🎵</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:10px;font-weight:700;color:#1DB954;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:5px;display:flex;align-items:center;gap:5px">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="#1DB954"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1" stroke="#000" stroke-width="2" fill="none"/></svg>
|
||||
${typeLabel}
|
||||
</div>
|
||||
<div style="font-size:18px;font-weight:700;margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2">${esc(data.name)}</div>
|
||||
<div style="font-size:13px;color:var(--text2);margin-bottom:10px">${esc(data.artist)}${count > 1 ? `<span style="color:var(--text3)"> · ${count} Tracks</span>` : ''}</div>
|
||||
${actionsHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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 */ }
|
||||
}
|
||||
@@ -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
|
||||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
const cover = document.getElementById('np-cover');
|
||||
if(cover) cover.classList.toggle('playing', playing);
|
||||
}
|
||||
|
||||
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 = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
|
||||
|
||||
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 = '<div style="text-align:center;padding:40px;color:rgba(255,255,255,.3)">Warteschlange leer</div>';
|
||||
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 = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
|
||||
item.onclick = () => { 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 = `
|
||||
<div class="thumb">${cv ? `<img src="${cv}">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
||||
<div><div class="title">${esc(s.title)}</div><div class="sub">${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}</div></div>`;
|
||||
const items = [
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId) navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
|
||||
];
|
||||
document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
|
||||
`<div class="np-sheet-item" data-idx="${i}">${item.icon}<span>${item.label}</span></div>`
|
||||
).join('');
|
||||
document.querySelectorAll('.np-sheet-item').forEach((el,i) => el.addEventListener('click', () => items[i].fn()));
|
||||
document.getElementById('np-sheet-overlay').classList.add('open');
|
||||
document.getElementById('np-sheet').classList.add('open');
|
||||
}
|
||||
export function closeNpMenu() {
|
||||
document.getElementById('np-sheet-overlay').classList.remove('open');
|
||||
document.getElementById('np-sheet').classList.remove('open');
|
||||
}
|
||||
+5
-261
@@ -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 = `<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||'—')}${t.trackExplicitness==='explicit'?'<span class="explicit-badge">E</span>':''}</div>
|
||||
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
|
||||
</div>
|
||||
<div class="gtrack-actions">
|
||||
${t.previewUrl ? `<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'"')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` : ''}
|
||||
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'"')})">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
el.onclick = () => { if(t.previewUrl) playPreview(t); };
|
||||
return el;
|
||||
}
|
||||
|
||||
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];
|
||||
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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 = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:2px">
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600"
|
||||
onclick="executeSpotifyDownload({type:'track',title:window._spotifyResolved.tracks[0].trackName,artist:window._spotifyResolved.tracks[0].artistName,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
|
||||
⬇ Track laden
|
||||
</button>
|
||||
${data.tracks[0]?.collectionName ? `<button class="btn-outline" style="font-size:12px;padding:9px 14px"
|
||||
onclick="openDlModal({artistName:window._spotifyResolved.tracks[0].artistName,trackName:window._spotifyResolved.tracks[0].collectionName,collectionName:window._spotifyResolved.tracks[0].collectionName,artworkUrl100:window._spotifyResolved.tracks[0].artworkUrl100||window._spotifyResolved.artworkUrl||'',isAlbum:true})">
|
||||
💿 ${esc(data.tracks[0].collectionName)}
|
||||
</button>` : ''}
|
||||
</div>`;
|
||||
} else if (data.type === 'album') {
|
||||
actionsHtml = `
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
|
||||
onclick="executeSpotifyDownload({type:'playlist',title:window._spotifyResolved.name,artist:window._spotifyResolved.artist,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
|
||||
⬇ Album laden (${count} Tracks)
|
||||
</button>`;
|
||||
} else if (data.type === 'playlist') {
|
||||
actionsHtml = `
|
||||
<div style="margin-top:6px">
|
||||
<label id="${plCbId}_label" style="display:inline-flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;margin-bottom:10px;background:var(--surface2);border:1px solid var(--border2);border-radius:20px;padding:6px 12px;transition:border-color .2s" onclick="void(0)">
|
||||
<input type="checkbox" id="${plCbId}" style="width:14px;height:14px;accent-color:#1DB954;cursor:pointer" onchange="
|
||||
document.getElementById('${plCbId}_wrap').style.display=this.checked?'':'none';
|
||||
document.getElementById('${plCbId}_label').style.borderColor=this.checked?'#1DB954':'';
|
||||
document.getElementById('${plCbId}_label').style.color=this.checked?'#1DB954':'';
|
||||
">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
Als Navidrome-Playlist speichern
|
||||
</label>
|
||||
<div id="${plCbId}_wrap" style="display:none;margin-bottom:10px">
|
||||
<input type="text" id="${plNmId}" value="${esc(data.name)}" placeholder="Playlist-Name…"
|
||||
style="background:var(--surface2);border:1px solid var(--border2);border-radius:8px;padding:8px 12px;font-size:13px;width:100%;box-sizing:border-box;color:var(--text);outline:none">
|
||||
</div>
|
||||
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600"
|
||||
onclick="spotifyDlAll('${plCbId}','${plNmId}')">
|
||||
⬇ Alle ${count} Tracks laden
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const artSize = data.type === 'playlist' ? '80px' : '110px';
|
||||
const artRadius = data.type === 'playlist' ? '8px' : '10px';
|
||||
const artHtml = art
|
||||
? `<img src="${art}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
||||
: '';
|
||||
|
||||
inner.innerHTML = `
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;background:var(--surface);border-radius:14px;padding:18px 16px;margin-bottom:4px;border:1px solid var(--border);border-top:3px solid #1DB954">
|
||||
<div style="width:${artSize};height:${artSize};border-radius:${artRadius};overflow:hidden;flex-shrink:0;background:var(--surface2);position:relative">
|
||||
${artHtml}
|
||||
<div style="width:100%;height:100%;display:${art?'none':'flex'};align-items:center;justify-content:center;font-size:32px;position:absolute;top:0;left:0">🎵</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:10px;font-weight:700;color:#1DB954;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:5px;display:flex;align-items:center;gap:5px">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="#1DB954"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1" stroke="#000" stroke-width="2" fill="none"/></svg>
|
||||
${typeLabel}
|
||||
</div>
|
||||
<div style="font-size:18px;font-weight:700;margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2">${esc(data.name)}</div>
|
||||
<div style="font-size:13px;color:var(--text2);margin-bottom:10px">${esc(data.artist)}${count > 1 ? `<span style="color:var(--text3)"> · ${count} Tracks</span>` : ''}</div>
|
||||
${actionsHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user