1323 lines
67 KiB
JavaScript
1323 lines
67 KiB
JavaScript
import { S } from './state.js';
|
|
import { esc, formatDur, toast } from './utils.js';
|
|
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js';
|
|
// Circular imports — safe (only used inside function bodies)
|
|
import { playQueue, dlSongLocal, playPreview, toggleStar, parseArtists } from './player.js';
|
|
import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from './ui.js';
|
|
import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
|
|
import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
|
|
import { fetchRomajiIndex, dispArtistById, romajiLookup } from './api.js';
|
|
import { loadSpotifyImporter } from './spotify.js';
|
|
import { loadSoundCloudImporter } from './soundcloud.js';
|
|
|
|
// ─── NAVIGATION HISTORY ───
|
|
export function _navSave() {
|
|
try { sessionStorage.setItem('ns_nav', JSON.stringify({stack: S._navStack, cursor: S._navCursor})); } catch {}
|
|
}
|
|
|
|
export function _navLoad() {
|
|
try {
|
|
const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null');
|
|
if(s && Array.isArray(s.stack) && s.stack.length) {
|
|
S._navStack = s.stack;
|
|
S._navCursor = s.cursor;
|
|
return true;
|
|
}
|
|
} catch {}
|
|
return false;
|
|
}
|
|
|
|
export function closeHeaderSearch() {
|
|
const overlay = document.getElementById('header-search-overlay');
|
|
const input = document.getElementById('header-search-input');
|
|
const clearBtn = document.getElementById('header-search-clear');
|
|
if (overlay) overlay.style.display = 'none';
|
|
if (input) input.value = '';
|
|
if (clearBtn) clearBtn.style.display = 'none';
|
|
clearTimeout(S._headerSearchTimer);
|
|
}
|
|
|
|
export function navigate(page, data=null) {
|
|
closeHeaderSearch();
|
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
|
const cls = ['discover','new-releases','recommendations','spotify-importer'].includes(page) ? 'active-blue' : 'active';
|
|
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
|
const pageEl = document.getElementById(`page-${page}`);
|
|
if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
|
|
closeSidebar();
|
|
if(['discover','new-releases','recommendations','random','spotify-importer'].includes(page)) switchNavMode('discover');
|
|
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
|
|
// ABS pages don't switch the bottom nav mode (they have their own sidebar section)
|
|
|
|
if(page==='home') loadHomeData();
|
|
if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists();
|
|
if(page==='albums' && !document.getElementById('albums-grid').children.length) loadAlbums();
|
|
if(page==='starred') loadStarred();
|
|
if(page==='random') loadRandom();
|
|
if(page==='artist-detail' && data) loadArtistDetail(data);
|
|
if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
|
|
if(page==='album-detail' && data) loadAlbumDetail(data);
|
|
if(page==='genre-detail' && data) loadGenreDetail(data);
|
|
if(page==='itunes-album-detail'&& data) loadItunesAlbumDetail(data);
|
|
if(page==='new-releases') loadNewReleases();
|
|
if(page==='playlists') loadPlaylists();
|
|
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
|
if(page==='recommendations') loadRecommendations();
|
|
if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
|
|
if(page==='audiobooks') {
|
|
import('./abs.js').then(m => {
|
|
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
|
|
});
|
|
}
|
|
if(page==='abs-book-detail' && data) {
|
|
import('./abs.js').then(m => m.loadAbsBookDetail(data.id));
|
|
}
|
|
|
|
if(!S._navJumping) {
|
|
S._navStack = S._navStack.slice(0, S._navCursor + 1);
|
|
const safe = data ? JSON.parse(JSON.stringify(data)) : null;
|
|
const last = S._navStack[S._navCursor];
|
|
if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) {
|
|
S._navStack.push({ page, data: safe });
|
|
}
|
|
S._navCursor = S._navStack.length - 1;
|
|
_navSave();
|
|
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
|
|
}
|
|
}
|
|
|
|
export function _navGoBack() {
|
|
if(document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; }
|
|
if(S.npOpen) { closeNowPlaying(); return; }
|
|
if(S._navCursor > 0) {
|
|
S._navCursor--;
|
|
const entry = S._navStack[S._navCursor];
|
|
_navSave();
|
|
S._navJumping = true;
|
|
navigate(entry.page, entry.data);
|
|
S._navJumping = false;
|
|
}
|
|
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
|
|
}
|
|
|
|
// ─── ARTIST IMAGE ───
|
|
export async function getArtistImage(artistId) {
|
|
if(S.artistImgCache[artistId] !== undefined) return S.artistImgCache[artistId];
|
|
try {
|
|
const r = await api('getArtistInfo2', `&id=${artistId}&count=0`);
|
|
const info = r.artistInfo2 || {};
|
|
const img = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null;
|
|
const isBlank = img && (img.includes('2a96cbd8b46e442fc41c2b86b821562f') || img.endsWith('/noimage/'));
|
|
S.artistImgCache[artistId] = isBlank ? null : img;
|
|
return S.artistImgCache[artistId];
|
|
} catch { S.artistImgCache[artistId] = null; return null; }
|
|
}
|
|
|
|
// ─── HOME ───
|
|
export async function loadHomeData() {
|
|
const hour = new Date().getHours();
|
|
const greeting = hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend';
|
|
const name = S.cfg.user ? `, ${S.cfg.user}` : '';
|
|
const el = document.getElementById('home-greeting');
|
|
if (el) el.textContent = `${greeting}${name} 👋`;
|
|
|
|
loadHeroCard();
|
|
loadGenreSection();
|
|
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20');
|
|
loadAlbumRow('home-recent', 'getAlbumList2', '&type=recent&size=20');
|
|
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
|
|
loadAlbumRow('home-frequent', 'getAlbumList2', '&type=frequent&size=20');
|
|
}
|
|
|
|
async function loadHeroCard() {
|
|
const el = document.getElementById('home-hero');
|
|
if (!el) return;
|
|
try {
|
|
// Combine recent + frequent into a pool and pick one randomly.
|
|
// This gives new users (no play history) recent albums, and returning users a mix.
|
|
const [freqRes, recRes] = await Promise.allSettled([
|
|
api('getAlbumList2', '&type=frequent&size=5'),
|
|
api('getAlbumList2', '&type=recent&size=5'),
|
|
]);
|
|
const freqList = freqRes.status === 'fulfilled' ? (freqRes.value.albumList2?.album || []) : [];
|
|
const recList = recRes.status === 'fulfilled' ? (recRes.value.albumList2?.album || []) : [];
|
|
// Deduplicate by id, tag source so we can show the right label
|
|
const freqIds = new Set(freqList.map(a => a.id));
|
|
const seen = new Set();
|
|
const pool = [...freqList, ...recList].filter(a => a.id && !seen.has(a.id) && seen.add(a.id));
|
|
const album = pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
|
|
if (!album) return;
|
|
const heroLabel = freqIds.has(album.id) ? 'Meistgespielt' : 'Zuletzt gehört';
|
|
const bgUrl = coverUrl(album.coverArt, 1024);
|
|
const thumbUrl = coverUrl(album.coverArt, 512);
|
|
el.innerHTML = `
|
|
${bgUrl ? `<img class="home-hero-bg" src="${bgUrl}" alt="">` : ''}
|
|
<div class="home-hero-overlay">
|
|
${thumbUrl ? `<img class="home-hero-cover" src="${thumbUrl}" alt="${esc(album.name)}">` : ''}
|
|
<div class="home-hero-info">
|
|
<div class="home-hero-label">${heroLabel}</div>
|
|
<div class="home-hero-title">${esc(album.name)}</div>
|
|
<div class="home-hero-artist">${esc(album.artist || '')}</div>
|
|
</div>
|
|
<button class="home-hero-play" title="Abspielen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
</button>
|
|
</div>`;
|
|
el.querySelector('.home-hero-cover')?.addEventListener('click', () => navigate('album-detail', album));
|
|
el.querySelector('.home-hero-artist').onclick = () => navigate('album-detail', album);
|
|
el.querySelector('.home-hero-play').onclick = () => quickPlayAlbum(album);
|
|
el.classList.add('loaded');
|
|
} catch { /* hero is optional — silent fail */ }
|
|
}
|
|
|
|
const _GENRE_COLORS = ['#e8445a','#3b82f6','#f97316','#22c55e','#a855f7','#06b6d4','#eab308','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#84cc16'];
|
|
function _genreColor(name) {
|
|
let h = 0;
|
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
return _GENRE_COLORS[h % _GENRE_COLORS.length];
|
|
}
|
|
|
|
let _allGenres = [];
|
|
let _genresExpanded = false;
|
|
const GENRE_LIMIT = () => window.innerWidth >= 700 ? 24 : 12;
|
|
|
|
async function loadGenreSection() {
|
|
const el = document.getElementById('home-genres');
|
|
const more = document.getElementById('home-genres-more');
|
|
if (!el) return;
|
|
try {
|
|
const r = await api('getGenres');
|
|
_allGenres = (r.genres?.genre || [])
|
|
.filter(g => (g.songCount || 0) > 0)
|
|
.sort((a, b) => (b.songCount || 0) - (a.songCount || 0));
|
|
_renderGenres();
|
|
if (_allGenres.length > GENRE_LIMIT() && more) more.style.display = '';
|
|
} catch { el.style.display = 'none'; }
|
|
// Re-sort by total play count once backend data arrives (non-blocking)
|
|
if (S.cfg.backendUrl && _allGenres.length) {
|
|
fetch(`${S.cfg.backendUrl}/api/genre-playcounts?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}`)
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(plays => {
|
|
if (!plays || !_allGenres.length) return;
|
|
_allGenres.sort((a, b) => (plays[b.value] || 0) - (plays[a.value] || 0));
|
|
_renderGenres();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
|
|
function _renderGenres() {
|
|
const el = document.getElementById('home-genres');
|
|
if (!el) return;
|
|
const list = _genresExpanded ? _allGenres : _allGenres.slice(0, GENRE_LIMIT());
|
|
el.innerHTML = '';
|
|
list.forEach(g => {
|
|
const card = document.createElement('div');
|
|
card.className = 'genre-card';
|
|
card.title = `${g.songCount} Titel`;
|
|
const color = _genreColor(g.value);
|
|
card.innerHTML = `
|
|
<div class="genre-card-bar" style="background:${color}"></div>
|
|
<div class="genre-card-name">${esc(g.value)}</div>
|
|
<button class="genre-card-play" title="Shuffle spielen">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
</button>`;
|
|
card.querySelector('.genre-card-play').onclick = (e) => { e.stopPropagation(); playGenre(g.value); };
|
|
card.onclick = () => navigate('genre-detail', g);
|
|
el.appendChild(card);
|
|
});
|
|
}
|
|
|
|
export function toggleAllGenres() {
|
|
_genresExpanded = !_genresExpanded;
|
|
const more = document.getElementById('home-genres-more');
|
|
if (more) more.textContent = _genresExpanded ? 'Weniger' : 'Alle anzeigen';
|
|
_renderGenres();
|
|
}
|
|
|
|
async function playGenre(genre) {
|
|
try {
|
|
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
|
|
const songs = r.songsByGenre?.song || [];
|
|
if (!songs.length) return;
|
|
const shuffled = [...songs].sort(() => Math.random() - 0.5);
|
|
playQueue(shuffled, 0);
|
|
} catch {}
|
|
}
|
|
|
|
async function loadGenreDetail(g) {
|
|
const genre = g.value || g;
|
|
const color = _genreColor(genre);
|
|
document.getElementById('genre-detail-name').textContent = genre;
|
|
document.getElementById('genre-detail-stats').textContent = g.songCount ? `${g.songCount} Titel` : '';
|
|
const coverWrap = document.getElementById('genre-cover-wrap');
|
|
coverWrap.style.background = `linear-gradient(135deg, ${color}33, ${color}11)`;
|
|
coverWrap.style.borderRadius = '10px';
|
|
coverWrap.style.border = `2px solid ${color}55`;
|
|
document.getElementById('genre-back-btn').onclick = () => _navGoBack();
|
|
|
|
const list = document.getElementById('genre-songs');
|
|
list.innerHTML = '<div class="spinner"></div>';
|
|
|
|
let songs = [];
|
|
try {
|
|
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=500`);
|
|
songs = r.songsByGenre?.song || [];
|
|
} catch { list.innerHTML = '<p style="color:var(--danger)">Fehler beim Laden</p>'; return; }
|
|
|
|
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
|
|
document.getElementById('btn-play-genre').onclick = () => {
|
|
const shuffled = [...songs].sort(() => Math.random() - 0.5);
|
|
playQueue(shuffled, 0);
|
|
};
|
|
|
|
list.innerHTML = '';
|
|
songs.forEach((s, i) => list.appendChild(makeSongItem(s, i, songs)));
|
|
}
|
|
|
|
export async function loadAlbumRow(id, method, extra) {
|
|
const c = document.getElementById(id);
|
|
if(!c) return;
|
|
c.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api(method, extra);
|
|
c.innerHTML = '';
|
|
(r.albumList2?.album || []).forEach(a => c.appendChild(makeAlbumCard(a)));
|
|
} catch(e) { c.innerHTML = `<div style="color:var(--text3);font-size:13px;padding:16px">${e.message}</div>`; }
|
|
}
|
|
|
|
// ─── ARTISTS ───
|
|
export async function loadArtists() {
|
|
const g = document.getElementById('artists-grid');
|
|
g.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api('getArtists');
|
|
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
|
|
if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {});
|
|
g.innerHTML = '';
|
|
all.forEach(a => {
|
|
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤');
|
|
c.classList.add('artist-card');
|
|
c.onclick = () => navigate('artist-detail', a);
|
|
g.appendChild(c);
|
|
getArtistImage(a.id).then(imgUrl => {
|
|
if(!imgUrl) return;
|
|
const artEl = c.querySelector('.card-art');
|
|
if(!artEl) return;
|
|
const overlay = artEl.querySelector('.play-overlay');
|
|
const img = document.createElement('img');
|
|
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
|
|
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
|
|
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
|
|
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
|
|
});
|
|
});
|
|
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
// ─── ALBUMS ───
|
|
let _albumSort = 'alphabeticalByName';
|
|
|
|
export async function loadAlbums(type = _albumSort) {
|
|
_albumSort = type;
|
|
document.querySelectorAll('#albums-sort-chips .sort-chip').forEach(c =>
|
|
c.classList.toggle('active', c.dataset.sort === type));
|
|
const g = document.getElementById('albums-grid');
|
|
g.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api('getAlbumList2', `&type=${type}&size=500`);
|
|
g.innerHTML = '';
|
|
(r.albumList2?.album || []).forEach(a => g.appendChild(makeAlbumCard(a)));
|
|
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
// ─── SHARED TOPSONG ROW ───
|
|
// Used by loadArtistDetail (top songs) and loadArtistAllSongs.
|
|
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);
|
|
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))';
|
|
const overlay = hero.querySelector('.artist-hero-overlay');
|
|
if(overlay) overlay.style.background = '';
|
|
[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;
|
|
|
|
// Phase 1 — parallel
|
|
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),
|
|
]);
|
|
|
|
// Verify name match — iTunes ranks by popularity, so "WRLD Tunes" would otherwise
|
|
// return "Juice WRLD" first. Require all query words to appear in the iTunes name.
|
|
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; // non-ASCII name (e.g. Japanese) — trust iTunes ranking
|
|
const rw = _normWords(r.artistName);
|
|
return [..._queryWords].every(w => rw.has(w));
|
|
});
|
|
const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null;
|
|
|
|
// Phase 2 — iTunes data (needs artist ID)
|
|
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;
|
|
if(img) {
|
|
hero.style.background = `url('${img}') center/cover no-repeat`;
|
|
if(overlay) overlay.style.background = 'linear-gradient(to right,rgba(7,7,15,.95) 0%,rgba(7,7,15,.35) 55%,rgba(7,7,15,.75) 100%)';
|
|
}
|
|
|
|
// ── 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.js').then(({ _applyShuffleRepeat }) => _applyShuffleRepeat());
|
|
await quickPlayArtist(artist);
|
|
};
|
|
|
|
actionBar.appendChild(playTopBtn);
|
|
actionBar.appendChild(shuffleBtn);
|
|
}
|
|
}
|
|
|
|
// ── Albums ──
|
|
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);
|
|
}
|
|
}
|
|
// Navidrome albums with no iTunes match go to downloaded
|
|
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, itunes }) => 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);
|
|
});
|
|
}
|
|
|
|
// No iTunes data at all — show Navidrome albums plainly
|
|
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="this.parentNode.innerHTML='<div class=\\'no-art\\'>💿</div>'">` : '<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 = 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.name)}','${esc(a.id||'')}')">${esc(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="this.parentNode.innerHTML='<div class=\\'no-art\\'>💿</div>'">` : '<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.`;
|
|
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>`; }
|
|
}
|
|
|
|
// ─── PLAYLISTS ───
|
|
export async function loadPlaylists() {
|
|
const g = document.getElementById('playlists-grid');
|
|
g.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api('getPlaylists');
|
|
const lists = r.playlists?.playlist || [];
|
|
g.innerHTML = '';
|
|
if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; }
|
|
lists.forEach(pl => {
|
|
const cv = coverUrl(pl.coverArt, 512);
|
|
const c = makeCard(pl.name, pl.songCount+' Titel', cv, '🎶');
|
|
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); };
|
|
c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl);
|
|
g.appendChild(c);
|
|
});
|
|
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
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); }
|
|
}
|
|
|
|
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 loadPlaylistDetail(pl) {
|
|
S._currentPlaylist = {...pl};
|
|
document.getElementById('playlist-detail-name').textContent = pl.name;
|
|
const cw = document.getElementById('playlist-cover');
|
|
const cv = coverUrl(pl.coverArt, 512);
|
|
cw.innerHTML = cv ? `<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">` : '<div class="no-art">🎶</div>';
|
|
document.getElementById('btn-edit-playlist').onclick = () => openEditPlModal(S._currentPlaylist);
|
|
const sl = document.getElementById('playlist-songs');
|
|
sl.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api('getPlaylist', `&id=${pl.id}`);
|
|
const full = r.playlist || {};
|
|
S._currentPlaylist = {...pl, ...full, id: pl.id};
|
|
const songs = full.entry || [];
|
|
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
|
|
document.getElementById('playlist-detail-stats').textContent = `${songs.length} Titel · ${totalMin} Min.`;
|
|
const descEl = document.getElementById('playlist-detail-desc');
|
|
if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; }
|
|
document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0);
|
|
document.getElementById('btn-shuffle-playlist').onclick = () => {
|
|
const shuffled = [...songs].sort(() => Math.random()-.5);
|
|
playQueue(shuffled, 0);
|
|
};
|
|
sl.innerHTML = '';
|
|
const plSearch = document.getElementById('playlist-search');
|
|
if (plSearch) plSearch.value = '';
|
|
songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs, { noArtistLink: true })));
|
|
_setupReorderMode(sl, songs, S._currentPlaylist);
|
|
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
export function filterPlaylist(val) {
|
|
const q = val.trim().toLowerCase();
|
|
document.querySelectorAll('#playlist-songs .song-item').forEach(item => {
|
|
const title = (item.querySelector('.song-title')?.textContent || '').toLowerCase();
|
|
const artist = (item.querySelector('.song-artist')?.textContent || '').toLowerCase();
|
|
if (!q || title.includes(q) || artist.includes(q)) { item.style.display = ''; return; }
|
|
const rm = romajiLookup(item.dataset.songId);
|
|
item.style.display = ((rm?.titleRomaji||'').toLowerCase().includes(q) || (rm?.artistRomaji||'').toLowerCase().includes(q)) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
// ─── PLAYLIST REORDER MODE ───────────────────────────────────────────────
|
|
let _reorderCtx = null;
|
|
|
|
function _setupReorderMode(container, songs, pl) {
|
|
// Exit any previous reorder mode silently when loading a new playlist.
|
|
if (_reorderCtx) _exitReorderMode(false);
|
|
_reorderCtx = { container, songs, pl };
|
|
const btn = document.getElementById('btn-reorder-playlist');
|
|
if (btn) btn.onclick = plReorderEnter;
|
|
const bar = document.getElementById('pl-reorder-bar');
|
|
if (bar) bar.style.display = 'none';
|
|
}
|
|
|
|
export function plReorderEnter() {
|
|
if (!_reorderCtx || _reorderCtx.active) return;
|
|
_reorderCtx.active = true;
|
|
const { container, songs } = _reorderCtx;
|
|
container.classList.add('pl-draggable');
|
|
// Block context-menu (long-press) on all items while reordering.
|
|
container.addEventListener('contextmenu', _blockCtxMenu, true);
|
|
|
|
let dragSrc = null;
|
|
let touchDragSrc = null;
|
|
|
|
Array.from(container.children).forEach((item, i) => {
|
|
const handle = document.createElement('div');
|
|
handle.className = 'pl-drag-handle';
|
|
handle.innerHTML = '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor"><circle cx="3" cy="2" r="1.3"/><circle cx="7" cy="2" r="1.3"/><circle cx="3" cy="7" r="1.3"/><circle cx="7" cy="7" r="1.3"/><circle cx="3" cy="12" r="1.3"/><circle cx="7" cy="12" r="1.3"/></svg>';
|
|
item.insertBefore(handle, item.firstChild);
|
|
item._songRef = songs[i];
|
|
|
|
// ── Desktop drag ──
|
|
let fromHandle = false;
|
|
handle.addEventListener('mousedown', () => { fromHandle = true; item.draggable = true; });
|
|
item.addEventListener('dragstart', e => {
|
|
if (!fromHandle) { e.preventDefault(); item.draggable = false; return; }
|
|
fromHandle = false; dragSrc = item;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', '');
|
|
setTimeout(() => item.classList.add('dragging'), 0);
|
|
});
|
|
item.addEventListener('dragend', () => {
|
|
fromHandle = false; item.draggable = false; dragSrc = null;
|
|
item.classList.remove('dragging');
|
|
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
});
|
|
item.addEventListener('dragover', e => {
|
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
|
if (item === dragSrc) return;
|
|
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
item.classList.add('drag-over');
|
|
});
|
|
item.addEventListener('dragleave', e => { if (!item.contains(e.relatedTarget)) item.classList.remove('drag-over'); });
|
|
item.addEventListener('drop', e => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
if (!dragSrc || item === dragSrc) return;
|
|
item.classList.remove('drag-over');
|
|
_doReorder(container, songs, dragSrc, item);
|
|
});
|
|
|
|
// ── Touch drag ──
|
|
handle.addEventListener('touchstart', e => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
touchDragSrc = item; item.classList.add('dragging');
|
|
}, { passive: false });
|
|
handle.addEventListener('touchmove', e => {
|
|
if (!touchDragSrc) return;
|
|
e.preventDefault();
|
|
const t = e.touches[0];
|
|
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc);
|
|
if (over) over.classList.add('drag-over');
|
|
}, { passive: false });
|
|
handle.addEventListener('touchend', e => {
|
|
if (!touchDragSrc) return;
|
|
const t = e.changedTouches[0];
|
|
touchDragSrc.classList.remove('dragging');
|
|
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc);
|
|
if (over) _doReorder(container, songs, touchDragSrc, over);
|
|
touchDragSrc = null;
|
|
}, { passive: false });
|
|
});
|
|
|
|
const bar = document.getElementById('pl-reorder-bar');
|
|
if (bar) bar.style.display = 'flex';
|
|
}
|
|
|
|
function _blockCtxMenu(e) { e.preventDefault(); e.stopPropagation(); }
|
|
|
|
function _itemAtPoint(container, x, y, exclude) {
|
|
return (document.elementsFromPoint(x, y) || [])
|
|
.find(el => el.classList?.contains('song-item') && el !== exclude && container.contains(el)) || null;
|
|
}
|
|
|
|
function _doReorder(container, songs, src, target) {
|
|
const all = Array.from(container.children);
|
|
const from = all.indexOf(src), to = all.indexOf(target);
|
|
container.insertBefore(src, from < to ? target.nextSibling : target);
|
|
const newOrder = Array.from(container.children).map(el => el._songRef);
|
|
songs.splice(0, songs.length, ...newOrder);
|
|
Array.from(container.children).forEach((el, i) => {
|
|
const n = el.querySelector('.song-num'); if (n) n.textContent = i + 1;
|
|
});
|
|
}
|
|
|
|
export function plReorderSave() { _exitReorderMode(true); }
|
|
export function plReorderCancel() { _exitReorderMode(false); }
|
|
|
|
function _exitReorderMode(save) {
|
|
if (!_reorderCtx) return;
|
|
const { container, songs, pl } = _reorderCtx;
|
|
_reorderCtx.active = false;
|
|
container.classList.remove('pl-draggable');
|
|
container.removeEventListener('contextmenu', _blockCtxMenu, true);
|
|
container.querySelectorAll('.pl-drag-handle').forEach(h => h.remove());
|
|
const bar = document.getElementById('pl-reorder-bar');
|
|
if (bar) bar.style.display = 'none';
|
|
if (save) _savePlaylistOrder(pl, songs);
|
|
}
|
|
|
|
async function _savePlaylistOrder(pl, songs) {
|
|
try {
|
|
const songIds = songs.map(s => `&songId=${s.id}`).join('');
|
|
await api('createPlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(pl.name)}${songIds}`);
|
|
toast('✅ Reihenfolge gespeichert');
|
|
} catch(e) { toast('Fehler beim Speichern: ' + e.message); }
|
|
}
|
|
|
|
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); }
|
|
}
|
|
|
|
// ─── STARRED / RANDOM ───
|
|
let _starredSort = 'newest';
|
|
|
|
export async function loadStarred(sort = _starredSort) {
|
|
_starredSort = sort;
|
|
document.querySelectorAll('#starred-sort-chips .sort-chip').forEach(c =>
|
|
c.classList.toggle('active', c.dataset.sort === sort));
|
|
const el = document.getElementById('starred-songs');
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
const searchEl = document.getElementById('starred-search');
|
|
if (searchEl) searchEl.value = '';
|
|
try {
|
|
const r = await api('getStarred2');
|
|
let songs = (r.starred2?.song || []).map(s => ({...s, _starred: true}));
|
|
if (sort === 'newest') songs.sort((a, b) => new Date(b.starred||0) - new Date(a.starred||0));
|
|
else if (sort === 'oldest') songs.sort((a, b) => new Date(a.starred||0) - new Date(b.starred||0));
|
|
else if (sort === 'az') songs.sort((a, b) => (a.title||'').localeCompare(b.title||''));
|
|
else if (sort === 'artist') songs.sort((a, b) => (a.artist||'').localeCompare(b.artist||''));
|
|
else if (sort === 'plays') songs.sort((a, b) => (b.playCount||0) - (a.playCount||0));
|
|
el.innerHTML = '';
|
|
if(!songs.length) { el.innerHTML = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; return; }
|
|
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true })));
|
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
export function filterStarred(val) {
|
|
const q = val.trim().toLowerCase();
|
|
document.querySelectorAll('#starred-songs .song-item').forEach(item => {
|
|
const title = (item.querySelector('.song-title')?.textContent || '').toLowerCase();
|
|
const artist = (item.querySelector('.song-artist')?.textContent || '').toLowerCase();
|
|
if (!q || title.includes(q) || artist.includes(q)) { item.style.display = ''; return; }
|
|
const rm = romajiLookup(item.dataset.songId);
|
|
item.style.display = ((rm?.titleRomaji||'').toLowerCase().includes(q) || (rm?.artistRomaji||'').toLowerCase().includes(q)) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
export async function loadRandom() {
|
|
const el = document.getElementById('random-songs');
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await api('getRandomSongs', '&size=50');
|
|
const songs = r.randomSongs?.song || [];
|
|
el.innerHTML = '';
|
|
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs)));
|
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
// ─── ROMAJI → HIRAGANA (lazy-loaded) ───
|
|
let _wanakana = null;
|
|
async function _romajiToHiragana(q) {
|
|
// Only attempt conversion if query is pure ASCII (likely romaji)
|
|
if (!/^[a-z\s'-]+$/i.test(q.trim())) return null;
|
|
try {
|
|
if (!_wanakana) _wanakana = await import('https://cdn.jsdelivr.net/npm/wanakana@5/esm/wanakana.js');
|
|
const h = _wanakana.toHiragana(q.trim(), { passRomaji: false });
|
|
// If conversion produced actual hiragana (not just the same ASCII), use it
|
|
return /[-ゟ]/.test(h) ? h : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function _mergeUniq(a, b) {
|
|
const seen = new Set(a.map(x => x.id));
|
|
return [...a, ...(b || []).filter(x => !seen.has(x.id))];
|
|
}
|
|
|
|
// ─── LOCAL SEARCH ───
|
|
export function onLocalSearch(val) {
|
|
clearTimeout(S.searchTimer);
|
|
if(!val.trim()) { navigate('home'); return; }
|
|
navigate('search');
|
|
S.searchTimer = setTimeout(() => doLocalSearch(val), 350);
|
|
}
|
|
|
|
export async function doLocalSearch(q, el) {
|
|
if (!el) el = document.getElementById('search-results');
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const hiragana = await _romajiToHiragana(q);
|
|
const isRomaji = /^[a-z\s'-]+$/i.test(q.trim());
|
|
const [r, r2, romajiIdx] = await Promise.all([
|
|
api('search3', `&query=${encodeURIComponent(q)}&songCount=30&albumCount=10&artistCount=6`),
|
|
hiragana ? api('search3', `&query=${encodeURIComponent(hiragana)}&songCount=20&albumCount=6&artistCount=4`) : Promise.resolve(null),
|
|
isRomaji ? fetchRomajiIndex() : Promise.resolve(null),
|
|
]);
|
|
const res = r.searchResult3 || {};
|
|
const res2 = r2?.searchResult3 || {};
|
|
el.innerHTML = '';
|
|
const artists = _mergeUniq(res.artist||[], res2.artist);
|
|
const albums = _mergeUniq(res.album ||[], res2.album);
|
|
|
|
// Merge Navidrome results with romaji index matches
|
|
const qLow = q.trim().toLowerCase();
|
|
const romajiHits = romajiIdx
|
|
? romajiIdx.filter(e => e.r.includes(qLow))
|
|
: [];
|
|
const songs = _mergeUniq(_mergeUniq(res.song||[], res2.song), romajiHits);
|
|
|
|
if(!artists.length && !albums.length && !songs.length) {
|
|
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse</p></div>'; return;
|
|
}
|
|
if(artists.length) {
|
|
const s = mkSection('Künstler');
|
|
const g = document.createElement('div'); g.className = 'scroll-row';
|
|
artists.forEach(a => {
|
|
const c = makeCard(a.name, a.albumCount+' Alben', null, '🎤');
|
|
c.classList.add('artist-card');
|
|
c.onclick = () => navigate('artist-detail', a);
|
|
g.appendChild(c);
|
|
getArtistImage(a.id).then(img => {
|
|
if(!img) return;
|
|
const artEl = c.querySelector('.card-art');
|
|
if(!artEl) return;
|
|
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
|
|
});
|
|
});
|
|
s.appendChild(g); el.appendChild(s);
|
|
}
|
|
if(albums.length) {
|
|
const s = mkSection('Alben');
|
|
const g = document.createElement('div'); g.className = 'scroll-row';
|
|
albums.forEach(a => g.appendChild(makeAlbumCard(a)));
|
|
s.appendChild(g); el.appendChild(s);
|
|
}
|
|
if(songs.length) {
|
|
const sec = mkSection('Titel');
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
songs.forEach((so,i) => list.appendChild(makeSongItem(so, i, songs)));
|
|
sec.appendChild(list); el.appendChild(sec);
|
|
}
|
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
// ─── HEADER SEARCH (persistent inline bar) ───
|
|
export function onHeaderSearch(val) {
|
|
const overlay = document.getElementById('header-search-overlay');
|
|
const clearBtn = document.getElementById('header-search-clear');
|
|
if (clearBtn) clearBtn.style.display = val ? 'block' : 'none';
|
|
clearTimeout(S._headerSearchTimer);
|
|
if (!val.trim()) { if (overlay) overlay.style.display = 'none'; return; }
|
|
if (overlay) {
|
|
// position:fixed — anchor top to actual header bottom so it works with any header height
|
|
const header = document.getElementById('mobile-header');
|
|
if (header) overlay.style.top = header.offsetHeight + 'px';
|
|
overlay.style.display = 'block';
|
|
}
|
|
const el = document.getElementById('header-search-results');
|
|
S._headerSearchTimer = setTimeout(() => doLocalSearch(val, el), 300);
|
|
}
|
|
|
|
// ─── MOBILE SEARCH (legacy no-op — replaced by inline header search) ───
|
|
export function onMobileSearch(val) { onHeaderSearch(val); }
|
|
|
|
// ─── GLOBAL (ITUNES) SEARCH ───
|
|
export function onGlobalSearch(val) {
|
|
clearTimeout(S.globalSearchTimer);
|
|
const el = document.getElementById('global-search-results');
|
|
if(!val.trim()) { el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein</p></div>'; return; }
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
S.globalSearchTimer = setTimeout(() => doGlobalSearch(val), 400);
|
|
}
|
|
|
|
export async function doGlobalSearch(q) {
|
|
const el = document.getElementById('global-search-results');
|
|
try {
|
|
const [tracks, albums, artists] = await Promise.all([
|
|
itunesSearch(q,'musicTrack',25),
|
|
itunesSearch(q,'album',10),
|
|
itunesSearch(q,'musicArtist',6)
|
|
]);
|
|
el.innerHTML = '';
|
|
|
|
const trackResults = tracks.filter(t => t.wrapperType === 'track');
|
|
const albumResults = albums;
|
|
|
|
if(artists.length) {
|
|
const s = mkSection('Künstler');
|
|
const row = document.createElement('div'); row.className = 'scroll-row';
|
|
artists.forEach(a => {
|
|
const c = makeCard(a.artistName, 'iTunes Künstler', null, '🎤');
|
|
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName);
|
|
row.appendChild(c);
|
|
itunesSearch(a.artistName,'album',1).then(res => {
|
|
if(!res[0]) return;
|
|
const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300');
|
|
if(!imgUrl) return;
|
|
const artEl = c.querySelector('.card-art'); if(!artEl) return;
|
|
const img = document.createElement('img');
|
|
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
|
|
const overlay = artEl.querySelector('.play-overlay');
|
|
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
|
|
img.onerror = () => { artEl.innerHTML = '<div class="no-art">🎤</div>'; if(overlay) artEl.appendChild(overlay); };
|
|
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
|
|
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
|
|
}).catch(() => {});
|
|
});
|
|
s.appendChild(row); el.appendChild(s);
|
|
}
|
|
if(albumResults.length) {
|
|
const s = mkSection('Alben');
|
|
const row = document.createElement('div'); row.className = 'scroll-row';
|
|
albumResults.forEach(a => {
|
|
const art = a.artworkUrl100?.replace('100x100','300x300');
|
|
const c = makeCard(a.collectionName, a.artistName, art, '💿');
|
|
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadItunesAlbum(a); };
|
|
c.querySelector('.dl-btn-overlay')?.remove();
|
|
const dlOverlay = document.createElement('button');
|
|
dlOverlay.className = 'dl-btn-overlay';
|
|
dlOverlay.title = 'Auf Server laden';
|
|
dlOverlay.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
|
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a}); };
|
|
c.querySelector('.play-overlay').appendChild(dlOverlay);
|
|
c.onclick = () => loadItunesAlbum(a);
|
|
row.appendChild(c);
|
|
});
|
|
s.appendChild(row); el.appendChild(s);
|
|
}
|
|
if(trackResults.length) {
|
|
const s = mkSection('Titel');
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
trackResults.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, trackResults)));
|
|
s.appendChild(list); el.appendChild(s);
|
|
}
|
|
if(!artists.length && !albumResults.length && !trackResults.length) {
|
|
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
|
|
}
|
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
|
}
|
|
|
|
export async function loadItunesAlbum(album) {
|
|
try {
|
|
const tracks = await itunesLookupAlbum(album.collectionId);
|
|
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
|
|
const el = document.getElementById('global-search-results');
|
|
const s = document.createElement('div'); s.className = 'search-section';
|
|
s.innerHTML = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
|
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
|
<div style="flex:1">
|
|
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
|
|
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
|
|
</div>
|
|
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'"')}})">⬇ Album laden</button>
|
|
</div>
|
|
<div class="search-section-title">Tracks</div>`;
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks)));
|
|
s.appendChild(list);
|
|
el.innerHTML = ''; el.appendChild(s);
|
|
} catch(e) { toast('Fehler: ' + e.message); }
|
|
}
|
|
|
|
export function loadItunesArtistResults(artistId, artistName) {
|
|
navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId });
|
|
}
|
|
|
|
// ─── NEW RELEASES ───
|
|
export async function loadNewReleases() {
|
|
const albumEl = document.getElementById('nr-albums');
|
|
const songEl = document.getElementById('nr-songs');
|
|
albumEl.innerHTML = '<div class="spinner"></div>'; songEl.innerHTML = '';
|
|
try {
|
|
const [albums, songs] = await Promise.all([itunesTopCharts(), itunesTopSongs()]);
|
|
albumEl.innerHTML = '';
|
|
albums.slice(0,30).forEach(a => {
|
|
const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl;
|
|
const c = makeCard(a.name, a.artistName, art, '💿');
|
|
c.onclick = () => itunesSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadItunesAlbum(res[0]); });
|
|
albumEl.appendChild(c);
|
|
});
|
|
songs.slice(0,40).forEach((s, i) => {
|
|
const el = document.createElement('div'); el.className = 'gtrack-item';
|
|
const art = s.artworkUrl?.replace('100x100','60x60');
|
|
el.innerHTML = `
|
|
<div class="gtrack-art">${art ? `<img src="${art}">` : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>`}</div>
|
|
<div class="gtrack-info">
|
|
<div class="gtrack-title">${esc(s.name)}</div>
|
|
<div class="gtrack-sub">${esc(s.artistName||'')}</div>
|
|
</div>
|
|
<div class="gtrack-actions">
|
|
<span style="font-size:11px;color:var(--text3);font-family:var(--mono)">#${i+1}</span>
|
|
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal({trackName:'${esc(s.name)}',artistName:'${esc(s.artistName||'')}',artworkUrl100:'${s.artworkUrl||''}',collectionName:''})">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
</button>
|
|
</div>`;
|
|
el.onclick = () => { itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
|
|
songEl.appendChild(el);
|
|
});
|
|
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
// ─── RECOMMENDATIONS ───
|
|
export async function loadRecommendations() {
|
|
const el = document.getElementById('reco-content');
|
|
const currentSong = S.queue[S.qIdx];
|
|
if(!currentSong) {
|
|
el.innerHTML = '<div class="empty-state"><div class="icon">🎵</div><p>Spiele zuerst Musik in deiner Bibliothek</p></div>';
|
|
return;
|
|
}
|
|
el.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const artist = currentSong.artist || '';
|
|
const title = currentSong.title || '';
|
|
let html = `<div style="padding:14px;background:var(--surface);border-radius:10px;margin-bottom:20px;display:flex;align-items:center;gap:12px">
|
|
<div style="font-size:28px">🎧</div>
|
|
<div><div style="font-size:12px;color:var(--text2)">Basierend auf</div><div style="font-size:15px;font-weight:700">${esc(title)}</div><div style="font-size:13px;color:var(--text2)">${esc(artist)}</div></div>
|
|
</div>`;
|
|
el.innerHTML = html + '<div class="spinner"></div>';
|
|
if(S.cfg.lastfmKey) {
|
|
await loadLastfmRecommendations(el, artist, title, html);
|
|
} else {
|
|
await loadItunesFallbackRecommendations(el, artist, title, html);
|
|
const note = document.createElement('div');
|
|
note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
|
|
note.innerHTML = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
|
|
el.appendChild(note);
|
|
}
|
|
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
async function loadLastfmRecommendations(el, artist, title, html) {
|
|
const BASE = 'https://ws.audioscrobbler.com/2.0';
|
|
const key = S.cfg.lastfmKey;
|
|
try {
|
|
const [simArtists, topTracks, simTracks] = await Promise.all([
|
|
fetch(`${BASE}/?method=artist.getSimilar&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=12`).then(r=>r.json()),
|
|
fetch(`${BASE}/?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
|
|
fetch(`${BASE}/?method=track.getSimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
|
|
]);
|
|
el.innerHTML = html;
|
|
const simArtistList = simArtists.similarartists?.artist || [];
|
|
const topTrackList = topTracks.toptracks?.track || [];
|
|
const simTrackList = simTracks.similartracks?.track || [];
|
|
if(simArtistList.length) {
|
|
el.innerHTML += `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:24px">`;
|
|
el.innerHTML += simArtistList.map(a => `<button class="reco-artist-chip" onclick="searchSimilarArtist('${esc(a.name)}')">${esc(a.name)}</button>`).join('');
|
|
el.innerHTML += '</div>';
|
|
}
|
|
if(simTrackList.length) {
|
|
const sec = document.createElement('div');
|
|
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Tracks</div>`;
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
simTrackList.slice(0,15).forEach(t => {
|
|
const row = document.createElement('div'); row.className = 'gtrack-item';
|
|
row.innerHTML = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
|
|
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||'')}</div></div>
|
|
<div class="gtrack-actions">
|
|
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(t.artist?.name||'')}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
|
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(t.artist?.name||'')}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
|
</div>`;
|
|
row.onclick = () => searchAndPreview(t.name, t.artist?.name||'');
|
|
list.appendChild(row);
|
|
});
|
|
sec.appendChild(list); el.appendChild(sec);
|
|
}
|
|
if(topTrackList.length) {
|
|
const sec = document.createElement('div'); sec.style.marginTop = '20px';
|
|
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Top Tracks von ${esc(artist)}</div>`;
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
topTrackList.slice(0,10).forEach(t => {
|
|
const row = document.createElement('div'); row.className = 'gtrack-item';
|
|
row.innerHTML = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
|
|
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||artist)}</div></div>
|
|
<div class="gtrack-actions">
|
|
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(artist)}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
|
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(artist)}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
|
</div>`;
|
|
row.onclick = () => searchAndPreview(t.name, artist);
|
|
list.appendChild(row);
|
|
});
|
|
sec.appendChild(list); el.appendChild(sec);
|
|
}
|
|
} catch { await loadItunesFallbackRecommendations(el, artist, title, html); }
|
|
}
|
|
|
|
async function loadItunesFallbackRecommendations(el, artist, title, html) {
|
|
const results = await itunesSearch(artist, 'musicTrack', 20);
|
|
el.innerHTML = html;
|
|
const sec = document.createElement('div');
|
|
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
|
|
const list = document.createElement('div'); list.className = 'song-list';
|
|
results.filter(t => t.artistName===artist).slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t])));
|
|
if(!list.children.length) results.slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t])));
|
|
sec.appendChild(list); el.appendChild(sec);
|
|
}
|
|
|
|
// ─── HELPERS ───
|
|
export function expandArtistBio() {
|
|
const el = document.getElementById('artist-bio-text');
|
|
if(el && window._artistBioFull) el.textContent = window._artistBioFull;
|
|
}
|
|
|
|
export function searchSimilarArtist(artistName) {
|
|
navigate('discover');
|
|
document.getElementById('global-search-input').value = artistName;
|
|
onGlobalSearch(artistName);
|
|
}
|
|
|
|
export async function searchAndPreview(trackName, artistName) {
|
|
try {
|
|
const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1);
|
|
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
|
|
} catch { toast('Suche fehlgeschlagen'); }
|
|
}
|
|
|
|
export async function s_goArtist(e, name, id) {
|
|
if(!name) return;
|
|
if(id) { navigate('artist-detail', {id, name}); return; }
|
|
try {
|
|
const r = await api('search3', `&query=${encodeURIComponent(name)}&artistCount=5&albumCount=0&songCount=0`);
|
|
const artists = r.searchResult3?.artist || [];
|
|
const match = artists.find(a => a.name.toLowerCase() === name.toLowerCase()) || artists[0];
|
|
if(match) navigate('artist-detail', {id: match.id, name: match.name});
|
|
else toast('Künstler nicht gefunden');
|
|
} catch { toast('Suche fehlgeschlagen'); }
|
|
}
|
|
|
|
export function s_goAlbum(e, albumId, albumName, artist, artistId) {
|
|
if(albumId) navigate('album-detail', {id:albumId, name:albumName, artist, artistId:artistId||'', coverArt:''});
|
|
}
|
|
|
|
export function playerBarGoAlbum() {
|
|
const s = S.queue[S.qIdx];
|
|
if(!s) return;
|
|
if(S.npOpen) closeNowPlaying();
|
|
if(s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''});
|
|
}
|
|
|
|
export function playerBarGoArtist() {
|
|
const s = S.queue[S.qIdx];
|
|
if(!s || !s.artist) return;
|
|
if(S.npOpen) closeNowPlaying();
|
|
navigate('artist-detail', {id: s.artistId||s.artist, name: s.artist});
|
|
}
|