Files
NovaSonic/src/views/detail.js
T

396 lines
21 KiB
JavaScript

import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, fetchRomajiIndex, dispArtistById, dispAlbumName, dispAlbum } from '../api.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';
// ─── 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;
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.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() || '?';
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;
const knownItunes = artist.itunesArtistId || null;
const [imgRes, albumRes, infoRes, topRes, iArtistRes] = await Promise.allSettled([
hasND ? getArtistImage(artist.id) : Promise.resolve(null),
hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null),
hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null),
hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null),
knownItunes
? Promise.resolve([{ artistId: knownItunes }])
: itunesSearch(artist.name, 'musicArtist', 5),
]);
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
const _queryWords = _normWords(artist.name);
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
const iArtistMatch = iArtistResults.find(r => {
if (_queryWords.size === 0) return true;
const rw = _normWords(r.artistName);
return [..._queryWords].every(w => rw.has(w));
});
const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null;
const [iAlbumsRes, iTracksRes] = itunesArtistId
? await Promise.allSettled([
itunesLookupArtistAlbums(itunesArtistId),
itunesLookupArtistSongs(itunesArtistId, 15),
])
: [{ status: 'rejected' }, { status: 'rejected' }];
// ── Hero ──
const img = imgRes.status === 'fulfilled' ? imgRes.value : null;
const _firstAlbums = albumRes.status === 'fulfilled' ? (albumRes.value?.artist?.album || []) : [];
const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null;
if(_bgCover || img) {
hero.style.background = '';
const bgEl = document.getElementById('artist-hero-bg');
if(bgEl) bgEl.style.backgroundImage = `url('${_bgCover || img}')`;
}
if(img) {
const pfpWrap = document.getElementById('artist-hero-pfp-wrap');
if(pfpWrap) {
const pfpImg = document.createElement('img');
pfpImg.className = 'artist-hero-pfp';
pfpImg.alt = '';
pfpImg.src = img;
pfpWrap.prepend(pfpImg);
const initial = document.getElementById('artist-hero-pfp-initial');
if(initial) initial.style.display = 'none';
}
}
// ── 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();
const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio;
if(short) {
window._artistBioFull = bio;
bioEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Über</div>
<div class="artist-bio" id="artist-bio-text">${esc(short)}${bio.length > 400
? ` <button onclick="expandArtistBio()" style="background:none;border:none;cursor:pointer;color:var(--accent);font-size:12px;padding:0;margin-left:6px">Mehr lesen</button>`
: ''}</div>`;
}
}
// ── Similar artists ──
const similar = info?.similarArtist || [];
if(similar.length && simEl) {
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
<div class="similar-chips">${similar.map(a =>
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
${esc(a.name)}</span>`).join('')}</div>`;
}
// ── Top Songs ──
const ndAlbums = albumRes.status === 'fulfilled' ? (albumRes.value?.artist?.album || []) : [];
const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
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 (iTunes)' : '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);
}
}
if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); }
}
function _normAlbum(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
const downloaded = [], partial = [], notDl = [];
const usedNdIds = new Set();
for(const ia of iAlbums) {
const iName = _normAlbum(ia.collectionName);
const iCount = ia.trackCount || 0;
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));
});
if(!match) {
notDl.push(ia);
} else if(iCount > 0 && match.songCount >= iCount) {
downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id);
} else {
partial.push({ nd: match, itunes: ia, have: match.songCount || 0, total: iCount }); usedNdIds.add(match.id);
}
}
for(const na of ndAlbums) {
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
}
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, itunes, have, total }) => {
const art = itunes?.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 = () => itunes ? navigate('itunes-album-detail', itunes) : 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(itunes) {
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: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.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('itunes-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, itunesData: 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 loadItunesAlbumDetail(album) {
document.getElementById('itunes-album-back-btn').onclick = () => _navGoBack();
document.getElementById('itunes-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('itunes-album-detail-artist').textContent = album.artistName || '';
const cw = document.getElementById('itunes-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('itunes-album-dl-btn');
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, itunesData: album, collectionId: album.collectionId });
const sl = document.getElementById('itunes-album-songs');
sl.innerHTML = '<div class="spinner"></div>';
try {
const tracks = await itunesLookupAlbum(album.collectionId);
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
document.getElementById('itunes-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>`; }
}
// ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) {
document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album.name);
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.`;
if (songs[0]) {
document.getElementById('album-detail-name').textContent = dispAlbum(songs[0]) || dispAlbumName(album.name);
artistEl.innerHTML = parseArtists(songs[0])
.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 ───
export function expandArtistBio() {
const el = document.getElementById('artist-bio-text');
if(el && window._artistBioFull) el.textContent = window._artistBioFull;
}