navigation.js — navigate(), history, closeHeaderSearch, _navGoBack home.js — home page, genres, album rows, loadArtists/loadAlbums, getArtistImage detail.js — artist/album/iTunes detail pages, quickPlay helpers, expandArtistBio playlists.js — playlists, playlist detail, reorder, starred, random search.js — local/header/global search, iTunes helpers, s_goArtist, playerBarGoX discover.js — new releases, recommendations (Last.fm + iTunes fallback) views.js — barrel re-export; all external imports unchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
import { S } from '../state.js';
|
|
import { esc, toast } from '../utils.js';
|
|
import { api, apiCached, apiIsCached, coverUrl, romajiLookup } from '../api.js';
|
|
import { makeCard, makeSongItem } from '../ui/components.js';
|
|
import { playQueue } from '../player/player.js';
|
|
import { openEditPlModal } from '../ui/ui.js';
|
|
// Circular — safe (called inside event handlers)
|
|
import { navigate } from './navigation.js';
|
|
import { quickPlayPlaylist } from './detail.js';
|
|
|
|
// ─── PLAYLISTS ───
|
|
export async function loadPlaylists() {
|
|
const g = document.getElementById('playlists-grid');
|
|
if (!apiIsCached('getPlaylists')) g.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const r = await apiCached('getPlaylists', '', 3 * 60 * 1000);
|
|
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.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';
|
|
}).catch(() => {});
|
|
});
|
|
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
|
}
|
|
|
|
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="_imgRetry(this,'🎶')">` : '<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 overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`);
|
|
if (overviewCard) overviewCard.querySelector('.card-sub').textContent = songs.length + ' Titel';
|
|
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([...songs].sort(() => Math.random()-.5), 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 ───
|
|
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 = '<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];
|
|
|
|
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 = '<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>`; }
|
|
}
|