import { S } from '../state.js'; import { esc, toast, formatGesamtdauer } from '../utils.js'; import { api, apiCached, apiIsCached, coverUrl, romajiLookup, dispArtistById, getArtistOverride } from '../api.js'; import { makeCard, makeAlbumCard, makeSongItem, mkSection } from '../ui/components.js'; import { playQueue } from '../player/player.js'; import { mischeListe } from '../player/queueMath.js'; import { openEditPlModal } from '../ui/ui.js'; // Circular — safe (called inside event handlers) import { navigate } from './navigation.js'; import { quickPlayPlaylist } from './detail.js'; import { getArtistImage, loadArtists } from './home.js'; // Besitzer einer Playlist, sofern es nicht die eigene ist. Navidrome liefert `owner` und `public` // in getPlaylists/getPlaylist mit; interessant ist die Angabe nur bei geteilten (oeffentlichen) // Listen — bei den eigenen waere "von mir" nur Rauschen. function _besitzer(pl) { const o = pl?.owner; if (!o || o === S.cfg.user) return ''; return o; } // ─── PLAYLISTS ─── export async function loadPlaylists() { const g = document.getElementById('playlists-grid'); if (!apiIsCached('getPlaylists')) g.innerHTML = '
'; try { const r = await apiCached('getPlaylists', '', 3 * 60 * 1000); const lists = r.playlists?.playlist || []; g.innerHTML = ''; if(!lists.length) { g.innerHTML = '
🎶

Keine Playlists vorhanden

'; return; } lists.forEach(pl => { const cv = coverUrl(pl.coverArt, 512); const von = _besitzer(pl); const c = makeCard(pl.name, pl.songCount+' Titel' + (von ? ' · von '+von : ''), cv, '🎶'); c.dataset.plOwner = von; c.dataset.plId = pl.id; c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); }; c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl); g.appendChild(c); }); lists.forEach(pl => { api('getPlaylist', `&id=${pl.id}`).then(r => { const count = (r.playlist?.entry || []).length; const card = g.querySelector(`[data-pl-id="${pl.id}"]`); if (card) card.querySelector('.card-sub').textContent = count + ' Titel' + (card.dataset.plOwner ? ' · von ' + card.dataset.plOwner : ''); }).catch(() => {}); }); } catch(e) { g.innerHTML = `

${e.message}

`; } } // ─── DEINE MUSIK (Seitenleiste) ─────────────────────────────────────────────── // Die Playlists stehen links als Liste, nicht hinter einem Knopf. Dieselbe gecachte Abfrage // wie die Uebersichtsseite (3 Min.), es entsteht also kein zusaetzlicher Netzverkehr: wer die // Seite oeffnet, hat den Stand schon, und umgekehrt. export async function renderSidebarPlaylists() { const el = document.getElementById('sidebar-playlists'); if (!el) return; try { const r = await apiCached('getPlaylists', '', 3 * 60 * 1000); const lists = r.playlists?.playlist || []; el.innerHTML = ''; if (!lists.length) { el.innerHTML = '
Noch keine Playlists — mit + anlegen.
'; return; } for (const pl of lists) { const von = _besitzer(pl); const cv = coverUrl(pl.coverArt, 128); const row = document.createElement('div'); row.className = 'lib-item'; row.dataset.plId = pl.id; row.title = pl.name + (von ? ' · von ' + von : ''); row.innerHTML = `
${cv ? `` : '🎶'}
` + `
${esc(pl.name)}
` + `
${pl.songCount || 0} Titel${von ? ' · ' + esc(von) : ''}
`; row.onclick = () => navigate('playlist-detail', pl); el.appendChild(row); } markSidebarPlaylist(S._currentPlaylist?.id); } catch { // Kein Alarm in der Seitenleiste: die Uebersichtsseite meldet Fehler ohnehin. el.innerHTML = '
Playlists nicht geladen.
'; } } /** * Tauscht das Cover EINER Zeile aus, nachdem es gerade hochgeladen oder entfernt wurde. * Noetig, weil die Cover-Adresse dieselbe bleibt (`pl-`): ein Neuzeichnen holte dasselbe * Bild aus dem Browser-Cache, und die Zeile zeigte weiter das alte Cover. */ export function bustSidebarCover(plId, coverArt) { const row = document.querySelector(`#sidebar-playlists .lib-item[data-pl-id="${plId}"] .lib-art`); if (!row) return; const cv = coverArt ? coverUrl(coverArt, 128) + '&_cb=' + Date.now() : ''; row.innerHTML = cv ? `` : '🎶'; } /** Hebt die laufend geoeffnete Playlist hervor. Aufgerufen aus navigate(). */ export function markSidebarPlaylist(plId) { const id = plId == null ? '' : String(plId); document.querySelectorAll('#sidebar-playlists .lib-item').forEach(el => { el.classList.toggle('active', !!id && el.dataset.plId === id); }); } 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 ? `` : '
🎶
'; document.getElementById('btn-edit-playlist').onclick = () => openEditPlModal(S._currentPlaylist); const sl = document.getElementById('playlist-songs'); sl.innerHTML = '
'; 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 gesamt = songs.reduce((s,t) => s+(t.duration||0), 0); const von = _besitzer({ ...pl, ...full }); document.getElementById('playlist-detail-stats').textContent = `${songs.length} Titel · ${formatGesamtdauer(gesamt)}` + (von ? ` · von ${von}` : ''); const overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`); if (overviewCard) overviewCard.querySelector('.card-sub').textContent = songs.length + ' Titel' + (von ? ' · von ' + von : ''); 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 = () => { playQueue(mischeListe(songs), 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 = `

${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(); 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 ─── let _reorderCtx = null; function _setupReorderMode(container, songs, pl) { 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'); container.addEventListener('contextmenu', _blockCtxMenu, true); let dragSrc = null, 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]; 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); }); 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); } } // ─── 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'); // getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — hier ist also die // Uebersicht "wem folge ich" zu haben, ohne einen zusaetzlichen Aufruf. _renderStarredArtists(r.starred2?.artist || []); _renderStarredAlbums(r.starred2?.album || []); 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

'; return; } const titel = document.getElementById('starred-songs-title'); if (titel) titel.style.display = songs.length ? '' : 'none'; songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true }))); } catch(e) { el.innerHTML = `

${e.message}

`; } } // Kuenstler, denen man folgt. Die Sortierchips oben gelten fuer die Songliste; hier waere eine // eigene Sortierung nur Ballast, also alphabetisch. // Wie viele gezeigt werden, richtet sich nach der Fensterbreite: eine Reihe voll, nicht mehr. Bei // 200 gefolgten Kuenstlern 200 Karten samt Bildern zu bauen, von denen man vier sieht, waere // Verschwendung — den Rest erreicht man ueber "Alle anzeigen" (Kuenstlerseite mit Gefolgt-Filter). const _KARTE = 145 + 12; // .scroll-row .card width + gap function _passendeAnzahl(wrap) { const breite = wrap?.clientWidth || document.getElementById('main-content')?.clientWidth || 900; // Eine Karte weniger, damit "Alle anzeigen" am Ende sichtbar ist statt angeschnitten. return Math.max(2, Math.floor(breite / _KARTE) - 1); } let _starredArtists = []; function _renderStarredArtists(artists) { _starredArtists = artists; // "✨ Neu" fuehrt in die Zeitleiste und steht in der Kopfzeile der Favoriten — dieselbe // Stelle wie in Android. Ohne gefolgte Kuenstler gaebe es dort nichts zu sehen. const neuBtn = document.getElementById('starred-news-btn'); if (neuBtn) neuBtn.style.display = artists.length ? '' : 'none'; const wrap = document.getElementById('starred-artists-section'); if (!wrap) return; wrap.innerHTML = ''; if (!artists.length) return; const sortiert = [...artists].sort((a,b) => (a.name||'').localeCompare(b.name||'')); const zeigen = _passendeAnzahl(wrap); const s = mkSection(`Künstler, denen du folgst (${artists.length})`); const row = document.createElement('div'); row.className = 'scroll-row'; sortiert.slice(0, zeigen).forEach(a => { const c = makeCard(dispArtistById(a.id, a.name), `${a.albumCount || 0} Alben`, null, '🎤'); c.classList.add('artist-card'); c.onclick = () => navigate('artist-detail', { id: a.id, name: a.name }); row.appendChild(c); const pin = getArtistOverride(a.id); const setzen = img => { if (!img) return; const art = c.querySelector('.card-art'); if (art) art.innerHTML = ``; }; if (pin?.image) setzen(pin.image); else if (a.coverArt) setzen(coverUrl(a.coverArt, 300)); // kein Extra-Aufruf noetig else getArtistImage(a.id).then(setzen); }); if (sortiert.length > zeigen) { const mehr = document.createElement('div'); mehr.className = 'card artist-card starred-more'; mehr.innerHTML = '
→
' + '
Alle anzeigen
' + `
+${sortiert.length - zeigen} weitere
`; mehr.onclick = () => { navigate('artists'); loadArtists('followed'); }; row.appendChild(mehr); } s.appendChild(row); wrap.appendChild(s); } // Bei Groessenaenderung neu aufteilen — sonst bleibt die Zahl auf dem Stand des ersten Aufbaus. let _resizeTimer = null; window.addEventListener('resize', () => { if (!document.getElementById('page-starred')?.classList.contains('active')) return; clearTimeout(_resizeTimer); _resizeTimer = setTimeout(() => { if (_starredArtists.length) _renderStarredArtists(_starredArtists); }, 200); }); function _renderStarredAlbums(albums) { const wrap = document.getElementById('starred-albums-section'); if (!wrap) return; wrap.innerHTML = ''; if (!albums.length) return; const s = mkSection(`Gemerkte Alben (${albums.length})`); const row = document.createElement('div'); row.className = 'scroll-row'; [...albums].sort((a,b) => new Date(b.starred||0) - new Date(a.starred||0)) .forEach(a => row.appendChild(makeAlbumCard(a))); s.appendChild(row); wrap.appendChild(s); } 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 = '
'; 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}

`; } }