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 } 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 } from './api.js';
import { loadSpotifyImporter } from './spotify.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 navigate(page, data=null) {
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==='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();
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-recent', 'getAlbumList2', '&type=recent&size=20');
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&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, prefer frequent (weighted taste signal), pad with recent
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() * Math.min(5, pool.length))] : null;
if (!album) return;
const bgUrl = coverUrl(album.coverArt, 1024);
const thumbUrl = coverUrl(album.coverArt, 512);
el.innerHTML = `
${bgUrl ? `` : ''}
${e.message}
`; } } // ─── 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 = ''; 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 = `${e.message}
`; } } // ─── ARTIST DETAIL ─── export async function loadArtistDetail(artist) { document.getElementById('artist-detail-name').textContent = artist.name; document.getElementById('artist-back-btn').onclick = () => _navGoBack(); 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 = ''; 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', 1), ]); const itunesArtistId = knownItunes ?? (iArtistRes.status === 'fulfilled' ? (iArtistRes.value[0]?.artistId ?? null) : 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(/]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim(); const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio; if(short) { window._artistBioFull = bio; bioEl.innerHTML = `${e.message}
`; } } // ─── 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'); const albumArtistParts = (album.artist || '').split(/\s*(?:\/|•)\s*/).filter(Boolean); artistEl.innerHTML = albumArtistParts.map((name, i) => { const aid = i === 0 ? (album.artistId || '') : ''; return `${esc(name)}`; }).join(' • ') || ''; const cw = document.getElementById('album-cover-wrap'); const cv = coverUrl(album.coverArt, 512); cw.innerHTML = cv ? `${e.message}
`; } } // ─── PLAYLISTS ─── export async function loadPlaylists() { const g = document.getElementById('playlists-grid'); g.innerHTML = ''; try { const r = await api('getPlaylists'); const lists = r.playlists?.playlist || []; g.innerHTML = ''; if(!lists.length) { g.innerHTML = 'Keine Playlists vorhanden
${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); } } 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 ? `${e.message}
`; } } 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(); item.style.display = (!q || title.includes(q) || artist.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 = ''; 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 = ''; 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 = 'Noch keine Favoriten
${e.message}
`; } } 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(); item.style.display = (!q || title.includes(q) || artist.includes(q)) ? '' : 'none'; }); } export async function loadRandom() { const el = document.getElementById('random-songs'); el.innerHTML = ''; 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 = `${e.message}
`; } } // ─── 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) { const el = document.getElementById('search-results'); el.innerHTML = ''; 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 = 'Keine Ergebnisse
${e.message}
`; } } // ─── MOBILE SEARCH ─── export function onMobileSearch(val) { clearTimeout(S._mobileSearchTimer); if(!val.trim()) { navigate('home'); return; } navigate('search'); S._mobileSearchTimer = setTimeout(() => doLocalSearch(val), 300); } // ─── GLOBAL (ITUNES) SEARCH ─── export function onGlobalSearch(val) { clearTimeout(S.globalSearchTimer); const el = document.getElementById('global-search-results'); if(!val.trim()) { el.innerHTML = 'Gib einen Suchbegriff ein
Keine Ergebnisse gefunden
Suche fehlgeschlagen: ${e.message}
`; } } 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 = `${e.message}
`; } } // ─── RECOMMENDATIONS ─── export async function loadRecommendations() { const el = document.getElementById('reco-content'); const currentSong = S.queue[S.qIdx]; if(!currentSong) { el.innerHTML = 'Spiele zuerst Musik in deiner Bibliothek
${e.message}
`; } } 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 += `