699 lines
39 KiB
JavaScript
699 lines
39 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast, fmtCount, formatGesamtdauer } from '../utils.js';
|
||
import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||
import { openArtistPin } from '../ui/artistPin.js';
|
||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
|
||
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
|
||
import { showCtxMenu, openDlModal } from '../ui/ui.js';
|
||
// Circular — safe (called inside event handlers / function bodies)
|
||
import { navigate, _navGoBack } from './navigation.js';
|
||
import { getArtistImage } from './home.js';
|
||
import { fetchEvents, openEventsModal } from './events.js';
|
||
|
||
// ─── QUICK PLAY HELPERS ───
|
||
export async function quickPlayAlbum(a) {
|
||
try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); }
|
||
catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
|
||
export async function quickPlayArtist(a) {
|
||
try {
|
||
toast('Lade Songs…');
|
||
const r = await api('getArtist', `&id=${a.id}`);
|
||
const albums = r.artist?.album || [];
|
||
if (!albums.length) { toast('Keine Alben gefunden'); return; }
|
||
const results = await Promise.all(albums.map(al => api('getAlbum', `&id=${al.id}`)));
|
||
const songs = results.flatMap(ar => ar.album?.song || []);
|
||
if (songs.length) playQueue(songs, 0);
|
||
else toast('Keine Songs gefunden');
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
|
||
export async function quickPlayPlaylist(pl) {
|
||
try { const r = await api('getPlaylist', `&id=${pl.id}`); playQueue(r.playlist?.entry || [], 0); }
|
||
catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
|
||
// ─── SHARED TOPSONG ROW ───
|
||
function _makeTopsongRow(s, num, subtitle, list, idx) {
|
||
const cv = coverUrl(s.coverArt, 128);
|
||
if (s.starred && !s._starred) s._starred = true;
|
||
const row = document.createElement('div'); row.className = 'topsong-item';
|
||
row.innerHTML = `<div class="topsong-num">${num}</div>
|
||
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
|
||
<div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div>${subtitle ? `<div style="font-size:11px;color:var(--text2)">${esc(subtitle)}</div>` : ''}</div>
|
||
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>
|
||
<div class="song-actions" style="flex-shrink:0">
|
||
<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 song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
|
||
</div>`;
|
||
row.onclick = () => playQueue(list, idx, { noShuffle: true });
|
||
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); });
|
||
const btns = row.querySelectorAll('.icon-btn');
|
||
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
|
||
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, list, idx); };
|
||
return row;
|
||
}
|
||
|
||
// ─── ARTIST DETAIL ───
|
||
export async function loadArtistDetail(artist) {
|
||
const nameEl = document.getElementById('artist-detail-name');
|
||
nameEl.textContent = artist.name;
|
||
const fansEl = document.getElementById('artist-detail-fans');
|
||
if (fansEl) { fansEl.textContent = ''; fansEl.classList.remove('show'); }
|
||
const _showFans = n => { if (fansEl && n) { fansEl.textContent = `${fmtCount(n)} Fans`; fansEl.classList.add('show'); } };
|
||
document.getElementById('artist-back-btn').onclick = () => _navGoBack();
|
||
if (S.cfg.showRomaji) {
|
||
fetchRomajiIndex()
|
||
.then(() => { nameEl.textContent = dispArtistById(artist.id, artist.name); })
|
||
.catch(() => {});
|
||
}
|
||
|
||
const hero = document.getElementById('artist-detail-hero');
|
||
const bioEl = document.getElementById('artist-bio-section');
|
||
const simEl = document.getElementById('artist-similar-section');
|
||
const tsEl = document.getElementById('artist-topsongs-section');
|
||
const albEl = document.getElementById('artist-albums-section');
|
||
hero.style.opacity = '0';
|
||
hero.style.transition = '';
|
||
hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))';
|
||
hero.querySelector('.artist-hero-img')?.remove();
|
||
const heroBgEl = document.getElementById('artist-hero-bg');
|
||
if(heroBgEl) heroBgEl.style.backgroundImage = '';
|
||
document.getElementById('artist-hero-pfp-wrap')?.querySelector('.artist-hero-pfp')?.remove();
|
||
const pfpInitial = document.getElementById('artist-hero-pfp-initial');
|
||
if(pfpInitial) { pfpInitial.textContent = artist.name?.[0]?.toUpperCase() || '?'; pfpInitial.style.display = ''; }
|
||
const overlay = hero.querySelector('.artist-hero-overlay');
|
||
[bioEl, simEl, tsEl, albEl].forEach(el => { if(el) el.innerHTML = ''; });
|
||
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
|
||
|
||
const hasND = !!artist.id;
|
||
// Manual pin (same-name fix): use the pinned Deezer artist for id/fans/image instead of guessing.
|
||
const _pin = hasND ? getArtistOverride(artist.id) : null;
|
||
const knownDeezer = _pin?.deezerArtistId || artist.deezerArtistId || null;
|
||
const knownFans = _pin?.fanCount ?? artist.deezerFanCount ?? null;
|
||
if (knownFans) _showFans(knownFans); // from the global-search card / pin — show immediately
|
||
|
||
// "Falscher Künstler?" pin button (local artists only) — top-right of the banner.
|
||
if (hasND && hero) {
|
||
hero.querySelector('.artist-pin-btn')?.remove();
|
||
const pinBtn = document.createElement('button');
|
||
pinBtn.className = 'artist-pin-btn';
|
||
pinBtn.textContent = '⚙';
|
||
pinBtn.title = _pin ? 'Gepinnt — anderen Deezer-Künstler wählen' : 'Falscher Künstler? Richtigen Deezer-Künstler wählen';
|
||
// Minimal: a small, subtle icon-only chip in the corner (not a text banner).
|
||
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
|
||
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
|
||
hero.appendChild(pinBtn);
|
||
|
||
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste: die Leiste ist "was ich jetzt
|
||
// tue" (abspielen, mischen), Folgen ist ein Zustand. Deshalb als Herz ins Banner, neben das
|
||
// Zahnrad. Verdrahtet wird es weiter unten, sobald `starred` aus getArtist da ist.
|
||
hero.querySelector('.artist-follow-btn')?.remove();
|
||
const folgeBtn = document.createElement('button');
|
||
folgeBtn.className = 'artist-follow-btn';
|
||
folgeBtn.style.cssText = 'position:absolute;top:10px;right:46px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:rgba(255,255,255,.72);cursor:pointer;backdrop-filter:blur(4px)';
|
||
hero.appendChild(folgeBtn);
|
||
}
|
||
|
||
// Fire all requests immediately so they run in parallel.
|
||
const imgP = _pin?.image ? Promise.resolve(_pin.image)
|
||
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
|
||
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
|
||
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
|
||
// id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||
// Namen zurueck, wenn es sie nicht kennt (aeltere Versionen ignorieren id einfach). Der Namensweg
|
||
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
|
||
// erste Treffer, und das war nicht zwingend der gemeinte.
|
||
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
|
||
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
|
||
const eventsP = fetchEvents(artist.name);
|
||
|
||
// Phase 1 — hero: only wait for albumP (fast local Navidrome call, no external API).
|
||
// imgP (getArtistImage) may be slow when uncached — it calls Last.fm via Navidrome.
|
||
// We reveal the hero with the album cover immediately, then patch in the artist photo async.
|
||
const [albumRes] = await Promise.allSettled([albumP]);
|
||
|
||
const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null;
|
||
if (hasND && artist.id) {
|
||
const folgeBtn = hero?.querySelector('.artist-follow-btn');
|
||
_verdrahteMerken(
|
||
folgeBtn, artist.id, _ndArtist?.starred, '', '',
|
||
`Du folgst ${artist.name}`, `${artist.name} folgen`,
|
||
);
|
||
// Ohne Pinnung laeuft die Deezer-Zuordnung ueber den Namen — bei gleichnamigen Kuenstlern
|
||
// kaemen dauerhaft die Neuheiten des Falschen.
|
||
if (folgeBtn && !_pin && !_ndArtist?.starred) {
|
||
folgeBtn.addEventListener('click', () => {
|
||
setTimeout(() => {
|
||
if (folgeBtn.classList.contains('is-starred')) {
|
||
toast('Tipp: Über ⚙ daneben den richtigen Deezer-Künstler festpinnen');
|
||
}
|
||
}, 900);
|
||
}, { once: true });
|
||
}
|
||
}
|
||
if (_ndArtist) cacheArtistSortNames([_ndArtist]);
|
||
const _firstAlbums = _ndArtist?.album || [];
|
||
const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null;
|
||
|
||
const _revealHero = () => requestAnimationFrame(() => {
|
||
hero.style.transition = 'opacity 0.25s ease';
|
||
hero.style.opacity = '1';
|
||
});
|
||
|
||
const bgEl = document.getElementById('artist-hero-bg');
|
||
const pfpWrap = document.getElementById('artist-hero-pfp-wrap');
|
||
|
||
if (_bgCover) {
|
||
hero.style.background = '';
|
||
const _pre = new Image();
|
||
_pre.onload = _pre.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${_bgCover}')`; _revealHero(); };
|
||
_pre.src = _bgCover;
|
||
} else {
|
||
_revealHero();
|
||
}
|
||
|
||
// Patch artist photo in async — resolves immediately if cached, otherwise after Last.fm
|
||
imgP.then(img => {
|
||
if (!img || !pfpWrap) return;
|
||
const pfpImg = document.createElement('img');
|
||
pfpImg.className = 'artist-hero-pfp';
|
||
pfpImg.alt = '';
|
||
pfpImg.onload = pfpImg.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${img}')`; };
|
||
pfpImg.src = img;
|
||
pfpWrap.prepend(pfpImg);
|
||
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
||
}).catch(() => {});
|
||
|
||
// Phase 2 — content (may be slow: Last.fm bio, Deezer, events)
|
||
const [infoRes, topRes, iArtistRes, eventsRes] = await Promise.allSettled([infoP, topP, iArtistP, eventsP]);
|
||
|
||
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
|
||
const _normName = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||
const _queryWords = _normWords(artist.name);
|
||
const _qNorm = _normName(artist.name);
|
||
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
|
||
// Prefer EXACT normalized-name matches. "Flay!" must not resolve to the far more popular "K.FLAY"
|
||
// (whose name merely contains the word "flay") — exact "flay" == "flay" wins over "kflay". Only fall
|
||
// back to the looser word-subset match when there's no exact match. Both lists are fan-sorted already.
|
||
const exactMatches = iArtistResults.filter(r => _normName(r.artistName) === _qNorm);
|
||
const looseMatches = iArtistResults.filter(r => {
|
||
if (_queryWords.size === 0) return true;
|
||
const rw = _normWords(r.artistName);
|
||
return [..._queryWords].every(w => rw.has(w));
|
||
});
|
||
const nameMatches = exactMatches.length ? exactMatches : looseMatches;
|
||
// Default pick = most popular name match (fixes "5× Joji": Deezer's order buries the real one).
|
||
let iArtistMatch = nameMatches[0] || null;
|
||
|
||
// Song-overlap tie-break: when several artists share the name AND this is a local (Navidrome)
|
||
// artist we own songs for, prefer the Deezer candidate whose top tracks actually overlap our
|
||
// library — each artist has different songs, so this nails the right person. Only runs on a real
|
||
// collision (≥2 name matches) and costs a few calls just for those.
|
||
if (!knownDeezer && hasND && nameMatches.length > 1) {
|
||
const _nt = s => (s || '').toLowerCase().replace(/\([^)]*\)/g, '').replace(/[^a-z0-9]/g, '');
|
||
const refTitles = new Set(
|
||
((topRes.status === 'fulfilled' ? topRes.value?.topSongs?.song : []) || [])
|
||
.map(s => _nt(s.title)).filter(Boolean)
|
||
);
|
||
if (refTitles.size) {
|
||
const cands = nameMatches.slice(0, 3);
|
||
const scored = await Promise.all(cands.map(async c => {
|
||
const tt = await deezerLookupArtistSongs(c.artistId, 15).catch(() => []);
|
||
const overlap = tt.reduce((n, t) => n + (refTitles.has(_nt(t.trackName)) ? 1 : 0), 0);
|
||
return { c, overlap };
|
||
}));
|
||
scored.sort((a, b) => b.overlap - a.overlap || (b.c.fanCount || 0) - (a.c.fanCount || 0));
|
||
if (scored[0]?.overlap > 0) iArtistMatch = scored[0].c;
|
||
}
|
||
}
|
||
|
||
const deezerArtistId = knownDeezer ?? iArtistMatch?.artistId ?? null;
|
||
if (!knownFans) _showFans(iArtistMatch?.fanCount); // local artist: show the matched Deezer artist's fans
|
||
|
||
// Non-local (Deezer) artist has no Navidrome photo — fetch the Deezer artist picture for the hero.
|
||
if (!hasND && deezerArtistId && pfpWrap) {
|
||
const dzImg = iArtistMatch?.artworkUrl100 || await deezerArtistImage(deezerArtistId);
|
||
if (dzImg) {
|
||
const pfpImg = document.createElement('img');
|
||
pfpImg.className = 'artist-hero-pfp';
|
||
pfpImg.onload = pfpImg.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${dzImg}')`; _revealHero(); };
|
||
pfpImg.src = dzImg;
|
||
pfpWrap.prepend(pfpImg);
|
||
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
|
||
}
|
||
}
|
||
|
||
const [iAlbumsRes, iTracksRes] = deezerArtistId
|
||
? await Promise.allSettled([
|
||
deezerLookupArtistAlbums(deezerArtistId, 100, artist.name),
|
||
deezerLookupArtistSongs(deezerArtistId, 15),
|
||
])
|
||
: [{ status: 'rejected' }, { status: 'rejected' }];
|
||
|
||
// ── Bio ──
|
||
const info = infoRes.status === 'fulfilled' ? infoRes.value?.artistInfo2 : null;
|
||
if(info?.biography && bioEl) {
|
||
const bio = info.biography.replace(/<a [^>]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim();
|
||
if(bio) {
|
||
bioEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Über</div>
|
||
<div class="artist-bio">${esc(bio)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Similar artists (with profile pictures) ──
|
||
// When pinned, use Deezer "related" for the pinned artist — Last.fm's list is keyed to the
|
||
// (possibly merged/wrong) Navidrome artist, so it goes "weird". Deezer related carry real photos.
|
||
if (simEl) {
|
||
let simList;
|
||
if (_pin?.deezerArtistId) {
|
||
const rel = await deezerRelatedArtists(_pin.deezerArtistId, 12);
|
||
simList = rel.map(r => ({
|
||
name: r.artistName, img: r.artworkUrl100 || '',
|
||
nav: { id: null, name: r.artistName, deezerArtistId: r.artistId, deezerFanCount: r.fanCount ?? null },
|
||
}));
|
||
} else {
|
||
simList = (info?.similarArtist || []).map(a => ({
|
||
name: a.name, img: coverUrl(a.coverArt || a.id, 160) || '',
|
||
nav: { id: a.id || a.name, name: a.name },
|
||
}));
|
||
}
|
||
if (simList.length) {
|
||
const _navAttr = n => esc(JSON.stringify(n)).replace(/"/g, '"');
|
||
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
||
<div class="scroll-row">${simList.map(a => `
|
||
<div class="artist-card" style="cursor:pointer;width:88px" onclick='navigate("artist-detail", ${_navAttr(a.nav)})'>
|
||
<div class="card-art" style="width:88px;height:88px;border-radius:50%;overflow:hidden">
|
||
${a.img ? `<img src="${esc(a.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">` : `<div class="no-art">🎤</div>`}
|
||
</div>
|
||
<div class="card-title" style="text-align:center;font-size:12px;margin-top:6px">${esc(a.name)}</div>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Top Songs ──
|
||
const ndAlbums = _ndArtist?.album || [];
|
||
// Navidrome can hold the same track twice (e.g. downloaded as a single AND as an album track),
|
||
// so getTopSongs returns duplicate rows ("PIXELATED KISSES" x2). Collapse by title+album for display.
|
||
const _rawTop = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
|
||
const _seenTop = new Set();
|
||
const topSongs = _rawTop.filter(s => {
|
||
const k = `${s.title}|${s.album}`.toLowerCase().replace(/[^a-z0-9|]/g, '');
|
||
return _seenTop.has(k) ? false : (_seenTop.add(k), true);
|
||
});
|
||
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
|
||
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];
|
||
|
||
if((topSongs.length || iTracks.length) && tsEl) {
|
||
tsEl.innerHTML = '';
|
||
if(topSongs.length) {
|
||
const h = document.createElement('div');
|
||
h.className = 'section-title'; h.style.marginBottom = '10px'; h.textContent = 'Top Songs';
|
||
tsEl.appendChild(h);
|
||
const list = document.createElement('div'); list.style.marginBottom = '8px';
|
||
topSongs.forEach((s, i) => list.appendChild(_makeTopsongRow(s, i + 1, s.album || '', topSongs, i)));
|
||
tsEl.appendChild(list);
|
||
if(ndAlbums.length) {
|
||
const total = ndAlbums.reduce((n, a) => n + (a.songCount || 0), 0);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-outline'; btn.style.cssText = 'font-size:12px;padding:7px 14px;margin-bottom:16px;margin-top:4px';
|
||
btn.textContent = `Alle ${total} Songs in Library`;
|
||
btn.onclick = () => navigate('artist-all-songs', { albums: ndAlbums, artist });
|
||
tsEl.appendChild(btn);
|
||
}
|
||
}
|
||
if(iTracks.length) {
|
||
const heading = topSongs.length ? 'Weltweit populär (Deezer)' : 'Populäre Titel';
|
||
const sec = document.createElement('div');
|
||
sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`;
|
||
const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px';
|
||
iTracks.forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, iTracks)));
|
||
sec.appendChild(list); tsEl.appendChild(sec);
|
||
}
|
||
}
|
||
|
||
// ── Action bar ──
|
||
const actionBar = document.getElementById('artist-action-bar');
|
||
if(actionBar) {
|
||
actionBar.innerHTML = '';
|
||
if(topSongs.length || ndAlbums.length) {
|
||
const playTopBtn = document.createElement('button');
|
||
playTopBtn.className = 'artist-action-btn';
|
||
playTopBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Top Songs`;
|
||
playTopBtn.onclick = () => topSongs.length ? playQueue(topSongs, 0) : quickPlayArtist(artist);
|
||
|
||
const shuffleBtn = document.createElement('button');
|
||
shuffleBtn.className = 'artist-action-btn';
|
||
shuffleBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg> Shuffle`;
|
||
shuffleBtn.onclick = async () => {
|
||
S.shuffle = true;
|
||
localStorage.setItem('ns_shuffle', '1');
|
||
import('../player/player.js').then(({ _applyShuffleRepeat }) => _applyShuffleRepeat());
|
||
await quickPlayArtist(artist);
|
||
};
|
||
|
||
actionBar.appendChild(playTopBtn);
|
||
actionBar.appendChild(shuffleBtn);
|
||
}
|
||
const events = eventsRes.status === 'fulfilled' ? (eventsRes.value || []) : [];
|
||
const evBtn = document.createElement('button');
|
||
evBtn.className = 'artist-action-btn';
|
||
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
|
||
evBtn.onclick = () => openEventsModal(events, artist.name);
|
||
actionBar.appendChild(evBtn);
|
||
|
||
}
|
||
|
||
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
|
||
}
|
||
|
||
function _normAlbum(s) {
|
||
// Keep Unicode letters/numbers (not just a-z0-9) — otherwise every Japanese/CJK title strips to an
|
||
// empty string, so all of an artist's JP singles collapse into one entry and even "match" each
|
||
// other (that hid inabakumori's whole singles discography behind one album).
|
||
return (s || '').toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
|
||
// Match each Deezer album to a Navidrome album by normalized name.
|
||
const allEntries = iAlbums.map(ia => {
|
||
const iName = _normAlbum(ia.collectionName);
|
||
const match = ndAlbums.find(a => {
|
||
const n = _normAlbum(a.name);
|
||
return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName));
|
||
});
|
||
return { ia, match };
|
||
});
|
||
// Deezer returns the same album several times (single/EP/album/regional/explicit variants), which
|
||
// rendered one owned album 3× ("PIXELATED KISSES"). Collapse to one entry per owned Navidrome album
|
||
// and one per not-owned title.
|
||
const entries = [];
|
||
const seenNd = new Set(), seenName = new Set();
|
||
for (const e of allEntries) {
|
||
if (e.match) { if (seenNd.has(e.match.id)) continue; seenNd.add(e.match.id); }
|
||
else { const k = _normAlbum(e.ia.collectionName); if (seenName.has(k)) continue; seenName.add(k); }
|
||
entries.push(e);
|
||
}
|
||
|
||
// Deezer's /artist/{id}/albums list omits nb_tracks, so the total is unknown here — which made every
|
||
// owned album look "incomplete" (e.g. "Moonlight 1 / 0"). Resolve the real track count from the
|
||
// album endpoint, but ONLY for matched (owned) albums, so it's a couple of calls, not the whole
|
||
// discography. An unknown total is then treated as complete, never a false "Unvollständig".
|
||
await Promise.all(entries
|
||
.filter(e => e.match && !(e.ia.trackCount > 0) && e.ia.collectionId)
|
||
.map(async e => {
|
||
const tracks = await deezerLookupAlbum(e.ia.collectionId).catch(() => []);
|
||
if (tracks.length) e.ia = { ...e.ia, trackCount: tracks.length };
|
||
}));
|
||
|
||
const downloaded = [], partial = [], notDl = [];
|
||
const usedNdIds = new Set();
|
||
for(const { ia, match } of entries) {
|
||
if(!match) { notDl.push(ia); continue; }
|
||
const have = match.songCount || 0;
|
||
const total = ia.trackCount || 0;
|
||
if(total > have) { partial.push({ nd: match, deezer: ia, have, total }); usedNdIds.add(match.id); }
|
||
else { downloaded.push({ nd: match, deezer: ia }); usedNdIds.add(match.id); }
|
||
}
|
||
for(const na of ndAlbums) {
|
||
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, deezer: null });
|
||
}
|
||
|
||
el.innerHTML = '';
|
||
downloaded.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
|
||
partial.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
|
||
notDl.sort((a, b) => new Date(b.releaseDate||0) - new Date(a.releaseDate||0));
|
||
|
||
function addGrid(title, count) {
|
||
const h = document.createElement('div');
|
||
h.className = 'section-title';
|
||
h.style.cssText = 'margin-bottom:12px;margin-top:20px;display:flex;align-items:center;gap:8px';
|
||
h.innerHTML = `${title}<span class="section-count">${count}</span>`;
|
||
el.appendChild(h);
|
||
const g = document.createElement('div'); g.className = 'grid grid-4'; el.appendChild(g);
|
||
return g;
|
||
}
|
||
|
||
if(downloaded.length) {
|
||
const g = addGrid('In Library', downloaded.length);
|
||
downloaded.forEach(({ nd }) => g.appendChild(makeAlbumCard({ ...nd, _fromArtist: artist })));
|
||
}
|
||
if(partial.length) {
|
||
const g = addGrid('Unvollständig', partial.length);
|
||
partial.forEach(({ nd, deezer, have, total }) => {
|
||
const art = deezer?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
|
||
const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿');
|
||
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); };
|
||
c.querySelector('.card-info').onclick = () => deezer ? navigate('partial-album-detail', { nd, deezer }) : navigate('album-detail', { ...nd, _fromArtist: artist });
|
||
const artEl = c.querySelector('.card-art');
|
||
const badge = document.createElement('div');
|
||
badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`;
|
||
artEl.appendChild(badge);
|
||
if(deezer) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden';
|
||
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
||
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId }); };
|
||
artEl.appendChild(btn);
|
||
}
|
||
g.appendChild(c);
|
||
});
|
||
}
|
||
if(notDl.length) {
|
||
const g = addGrid('Noch nicht heruntergeladen', notDl.length);
|
||
notDl.forEach(ia => {
|
||
const art = ia.artworkUrl100?.replace('100x100','300x300');
|
||
const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿');
|
||
c.onclick = () => navigate('deezer-album-detail', ia);
|
||
const artEl = c.querySelector('.card-art');
|
||
const btn = document.createElement('button');
|
||
btn.className = 'card-dl-btn'; btn.title = 'Album laden';
|
||
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><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>`;
|
||
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, deezerData: ia, collectionId: ia.collectionId }); };
|
||
artEl.appendChild(btn);
|
||
g.appendChild(c);
|
||
});
|
||
}
|
||
if(!downloaded.length && !partial.length && !notDl.length && ndAlbums.length) {
|
||
const g = addGrid('Alben', ndAlbums.length);
|
||
ndAlbums.forEach(a => g.appendChild(makeAlbumCard({ ...a, _fromArtist: artist })));
|
||
}
|
||
}
|
||
|
||
export async function loadArtistAllSongs({ albums, artist }) {
|
||
document.getElementById('artist-songs-title').textContent = `Alle Songs · ${artist.name}`;
|
||
document.getElementById('artist-songs-back-btn').onclick = () => _navGoBack();
|
||
const el = document.getElementById('artist-songs-list');
|
||
el.innerHTML = '<div class="spinner"></div>';
|
||
const results = await Promise.allSettled(albums.map(a => api('getAlbum', `&id=${a.id}`)));
|
||
el.innerHTML = '';
|
||
const allSongs = results
|
||
.filter(r => r.status === 'fulfilled')
|
||
.flatMap(r => { const alb = r.value?.album; return (alb?.song || []).map(s => ({ ...s, _alb: alb })); })
|
||
.sort((a, b) => {
|
||
if(a._alb?.year !== b._alb?.year) return (b._alb?.year||0) - (a._alb?.year||0);
|
||
if(a._alb?.id !== b._alb?.id) return (a._alb?.name||'').localeCompare(b._alb?.name||'');
|
||
return (a.trackNumber||0) - (b.trackNumber||0);
|
||
});
|
||
let curId = '';
|
||
allSongs.forEach((s, i) => {
|
||
if(s._alb?.id !== curId) {
|
||
curId = s._alb?.id || '';
|
||
const h = document.createElement('div');
|
||
h.style.cssText = 'font-size:12px;font-weight:700;color:var(--text2);padding:12px 0 4px;border-top:1px solid var(--border);margin-top:4px';
|
||
h.textContent = s._alb?.name || '';
|
||
el.appendChild(h);
|
||
}
|
||
el.appendChild(_makeTopsongRow(s, s.trackNumber || '', null, allSongs, i));
|
||
});
|
||
}
|
||
|
||
// ─── MARKIEREN (Künstler + Album) ───
|
||
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
|
||
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
|
||
// Zustand kommt aus `starred` in getArtist/getAlbum.
|
||
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
|
||
|
||
/**
|
||
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
|
||
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
|
||
*/
|
||
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
|
||
if (!btn || !id) return;
|
||
let an = !!markiert;
|
||
const malen = () => {
|
||
const txt = an ? textAn : textAus;
|
||
btn.innerHTML = _HERZ(an) + (txt ? `<span>${txt}</span>` : '');
|
||
btn.classList.toggle('is-starred', an);
|
||
if (!txt) btn.title = an ? meldungAn : meldungAus;
|
||
};
|
||
malen();
|
||
btn.onclick = async () => {
|
||
const vorher = an;
|
||
an = !an; malen();
|
||
try {
|
||
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
|
||
invalidateApiCache('getStarred2');
|
||
// Der "Gefolgt"-Filter auf der Künstlerseite hält eine eigene kurze Liste — die muss mit weg,
|
||
// sonst zeigt er nach dem Entfolgen noch eine halbe Minute den alten Stand.
|
||
import('./home.js').then(m => m.invalidateGefolgt?.()).catch(() => {});
|
||
toast(an ? meldungAn : meldungAus);
|
||
} catch (e) {
|
||
an = vorher; malen();
|
||
toast('Fehler: ' + e.message);
|
||
}
|
||
};
|
||
}
|
||
|
||
export async function loadDeezerAlbumDetail(album) {
|
||
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
|
||
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
|
||
// Deezer liefert die Künstler-ID in jedem Albumtreffer gleich mit — ohne sie ist der Name nur
|
||
// toter Text (z. B. bei Alben aus /artist/{id}/albums, wo Deezer das artist-Objekt weglässt).
|
||
const dzArtistEl = document.getElementById('deezer-album-detail-artist');
|
||
dzArtistEl.textContent = album.artistName || '';
|
||
dzArtistEl.onclick = null;
|
||
dzArtistEl.classList.toggle('is-link', !!(album.artistId && album.artistName));
|
||
if (album.artistId && album.artistName) {
|
||
dzArtistEl.onclick = () => navigate('artist-detail', {
|
||
id: null, name: album.artistName, deezerArtistId: album.artistId, deezerFanCount: null,
|
||
});
|
||
}
|
||
const cw = document.getElementById('deezer-album-cover-wrap');
|
||
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
|
||
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||
const dlBtn = document.getElementById('deezer-album-dl-btn');
|
||
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, deezerData: album, collectionId: album.collectionId });
|
||
const sl = document.getElementById('deezer-album-songs');
|
||
sl.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const tracks = await deezerLookupAlbum(album.collectionId);
|
||
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
|
||
document.getElementById('deezer-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
|
||
sl.innerHTML = '';
|
||
if(!tracks.length) { sl.innerHTML = '<div style="padding:16px;color:var(--text3)">Keine Tracks gefunden</div>'; return; }
|
||
tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks)));
|
||
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||
}
|
||
|
||
export async function loadPartialAlbumDetail({ nd, deezer }) {
|
||
document.getElementById('partial-album-back-btn').onclick = () => _navGoBack();
|
||
document.getElementById('partial-album-detail-name').textContent = nd.name || '—';
|
||
document.getElementById('partial-album-detail-artist').textContent = nd.artist || deezer?.artistName || '';
|
||
const art = (deezer?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600')
|
||
|| (nd.coverArt ? coverUrl(nd.coverArt, 512) : '');
|
||
const cw = document.getElementById('partial-album-cover-wrap');
|
||
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||
|
||
const dlEl = document.getElementById('partial-album-downloaded');
|
||
const missEl = document.getElementById('partial-album-missing');
|
||
const statsEl = document.getElementById('partial-album-detail-stats');
|
||
dlEl.innerHTML = missEl.innerHTML = '<div class="spinner"></div>';
|
||
|
||
try {
|
||
const [ndRes, iTracks] = await Promise.all([
|
||
api('getAlbum', `&id=${nd.id}`),
|
||
deezer?.collectionId ? deezerLookupAlbum(deezer.collectionId) : Promise.resolve([]),
|
||
]);
|
||
const ndSongs = (ndRes.album?.song || []).sort((a,b) => (a.track||999)-(b.track||999));
|
||
|
||
// Cross-reference by normalized track name
|
||
const norm = s => (s||'').toLowerCase().replace(/[^a-z0-9]/g,'').trim();
|
||
const ndNames = new Set(ndSongs.map(s => norm(s.title)));
|
||
const missing = iTracks.filter(t => !ndNames.has(norm(t.trackName))).sort((a,b)=>(a.trackNumber||999)-(b.trackNumber||999));
|
||
|
||
statsEl.textContent = `${ndSongs.length} heruntergeladen · ${missing.length} fehlen`;
|
||
|
||
// Play button plays downloaded songs
|
||
document.getElementById('partial-album-play-btn').onclick = () => {
|
||
if(ndSongs.length) playQueue(ndSongs, 0);
|
||
};
|
||
|
||
// Download button offers missing via Deezer
|
||
document.getElementById('partial-album-dl-btn').onclick = () => {
|
||
if(deezer) openDlModal({ artistName: deezer.artistName, trackName: deezer.collectionName, collectionName: deezer.collectionName, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId });
|
||
};
|
||
|
||
// Section: downloaded
|
||
dlEl.innerHTML = '';
|
||
if(ndSongs.length) {
|
||
const h = document.createElement('div');
|
||
h.className = 'section-title';
|
||
h.style.cssText = 'margin:16px 0 8px';
|
||
h.innerHTML = `Heruntergeladen <span class="section-count">${ndSongs.length}</span>`;
|
||
dlEl.appendChild(h);
|
||
const list = document.createElement('div'); list.className = 'song-list'; dlEl.appendChild(list);
|
||
ndSongs.forEach((s,i) => list.appendChild(makeSongItem(s, i, ndSongs)));
|
||
}
|
||
|
||
// Section: missing
|
||
missEl.innerHTML = '';
|
||
if(missing.length) {
|
||
const h = document.createElement('div');
|
||
h.className = 'section-title';
|
||
h.style.cssText = 'margin:16px 0 8px;opacity:0.7';
|
||
h.innerHTML = `Nicht heruntergeladen <span class="section-count">${missing.length}</span>`;
|
||
missEl.appendChild(h);
|
||
const list = document.createElement('div'); list.className = 'song-list'; missEl.appendChild(list);
|
||
missing.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, missing)));
|
||
} else {
|
||
missEl.innerHTML = '';
|
||
}
|
||
} catch(e) {
|
||
dlEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`;
|
||
missEl.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// ─── ALBUM DETAIL ───
|
||
export async function loadAlbumDetail(album) {
|
||
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
|
||
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
|
||
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
|
||
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
|
||
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
|
||
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
|
||
document.getElementById('album-back-btn').onclick = () =>
|
||
S._navCursor > 0 ? _navGoBack() : navigate('albums');
|
||
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
|
||
const artistEl = document.getElementById('album-detail-artist');
|
||
artistEl.innerHTML = parseArtists(album)
|
||
.map(a => `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${esc(a.id||'')}')">${esc(dispArtistById(a.id, a.name))}</span>`)
|
||
.join('<span style="color:var(--text3)"> • </span>') || '';
|
||
const cw = document.getElementById('album-cover-wrap');
|
||
const cv = coverUrl(album.coverArt, 512);
|
||
cw.innerHTML = cv ? `<img src="${cv}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||
const sl = document.getElementById('album-songs');
|
||
sl.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const r = await api('getAlbum', `&id=${album.id}`);
|
||
const songs = r.album?.song || [];
|
||
const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
|
||
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${formatGesamtdauer(gesamt)}`;
|
||
_verdrahteMerken(
|
||
document.getElementById('btn-star-album'), album.id, r.album?.starred,
|
||
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
|
||
);
|
||
// r.album has sortName (album sort tag) — use it; nav album may not have sortName
|
||
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
|
||
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
|
||
// first track — for soundtracks/compilations track 1's artist (e.g. TWICE) is not the album artist.
|
||
artistEl.innerHTML = parseArtists(r.album)
|
||
.map(a => `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${esc(a.id||'')}')">${esc(dispArtistById(a.id, a.name))}</span>`)
|
||
.join('<span style="color:var(--text3)"> • </span>') || '';
|
||
document.getElementById('btn-play-album').onclick = () => playQueue(songs, 0);
|
||
document.getElementById('btn-dl-album').onclick = () => {
|
||
songs.forEach((s,i) => setTimeout(() => dlSongLocal(s), i*400));
|
||
toast(`⬇ ${songs.length} Titel Download gestartet`);
|
||
};
|
||
sl.innerHTML = '';
|
||
songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs)));
|
||
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||
}
|
||
|
||
// ─── HELPERS ───
|
||
|