album-back-btn war die einzige Detailseite, die per navigate() VORWAERTS auf die Albenliste sprang statt einen Schritt im Verlauf zurueck. Dadurch landete man nicht dort, wo man das Album angeklickt hatte (z. B. Home), und es kam ein zusaetzlicher History-Eintrag auf den Stapel, durch den sich der Browser-Zurueck- Knopf erst arbeiten musste. Jetzt _navGoBack() wie bei Artist, Genre, Deezer- und Partial-Album; die Albenliste bleibt nur das Ziel, wenn es keinen Verlauf gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
616 lines
35 KiB
JavaScript
616 lines
35 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast, fmtCount } from '../utils.js';
|
||
import { api, 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);
|
||
}
|
||
|
||
// 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);
|
||
const topP = hasND ? api('getTopSongs', `&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 (_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));
|
||
});
|
||
}
|
||
|
||
export async function loadDeezerAlbumDetail(album) {
|
||
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
|
||
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
|
||
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || '';
|
||
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 totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
|
||
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
|
||
// 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 ───
|
||
|