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 = `
${num}
${cv ? `` : '
🎵
'}
${esc(s.title)}
${subtitle ? `
${esc(subtitle)}
` : ''}
${s.playCount ? s.playCount.toLocaleString() : '—'}
`; 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 = '
'; 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(/]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim(); if(bio) { bioEl.innerHTML = `
Über
${esc(bio)}
`; } } // ── 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 = `
Ähnliche Künstler
${simList.map(a => `
${a.img ? `` : `
🎤
`}
${esc(a.name)}
`).join('')}
`; } } // ── 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 = `
${heading}
`; 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 = ` Top Songs`; playTopBtn.onclick = () => topSongs.length ? playQueue(topSongs, 0) : quickPlayArtist(artist); const shuffleBtn = document.createElement('button'); shuffleBtn.className = 'artist-action-btn'; shuffleBtn.innerHTML = ` 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 = ` Konzerte${events.length ? ` (${events.length})` : ''}`; 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 = '
'; // 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}${count}`; 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 = ``; 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 = ``; 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 = '
'; 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 ? `` : '
💿
'; 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 = '
'; 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 = '
Keine Tracks gefunden
'; return; } tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks))); } catch(e) { sl.innerHTML = `

${e.message}

`; } } 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 ? `` : '
💿
'; 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 = '
'; 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 ${ndSongs.length}`; 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 ${missing.length}`; 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 = `

${e.message}

`; 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 => `${esc(dispArtistById(a.id, a.name))}`) .join(' • ') || ''; const cw = document.getElementById('album-cover-wrap'); const cv = coverUrl(album.coverArt, 512); cw.innerHTML = cv ? `` : '
💿
'; const sl = document.getElementById('album-songs'); sl.innerHTML = '
'; 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 => `${esc(dispArtistById(a.id, a.name))}`) .join(' • ') || ''; 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 = `

${e.message}

`; } } // ─── HELPERS ───