Files
NovaSonic/src/views/playlists.js
T
marcandClaude Opus 5 9afd73ae7f feat(web): Playlists und Favoriten als Liste in der Seitenleiste
Statt zweier Knoepfe, die erst eine Uebersichtsseite oeffnen, steht unter den
Navigationsrubriken der Abschnitt "Deine Musik": angepinnt Favoriten und "Neu von
deinen Kuenstlern", darunter die Playlists mit Cover und Titelzahl.

Links und nicht rechts, weil rechts belegt ist: dort faehrt die Warteschlange als
290-px-Panel ein und am Rand sitzt der Rollbalken aus 3.1.

Der Listenteil rollt eigenstaendig (nav ist jetzt eine Flex-Spalte), auf niedrigen
Fenstern rollt als Rueckfall die ganze Leiste. Dieselbe gecachte Abfrage wie die
Uebersichtsseite; nach Anlegen/Umbenennen/Loeschen wird der Cache verworfen und neu
gezeichnet, ein Coverwechsel tauscht nur die betroffene Zeile (gleiche Adresse pl-<id>,
sonst kaeme dasselbe Bild aus dem Browser-Cache).

Die Kachel-Uebersicht bleibt per "Alle" erreichbar, die untere Leiste auf dem Handy
bleibt unveraendert.

Ausserdem: in 3.2 uebersehene Mischstelle nachgezogen — der Shuffle-Knopf der
Playlist-Seite benutzte weiter sort(() => Math.random()-.5). Mein Suchmuster hatte nur
die Schreibweise mit 0.5 erfasst, nicht die mit .5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 19:40:56 +02:00

407 lines
19 KiB
JavaScript

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 = '<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 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 = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── 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 = '<div class="lib-empty">Noch keine Playlists — mit + anlegen.</div>';
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 =
`<div class="lib-art">${cv ? `<img src="${cv}" loading="lazy" onerror="_imgRetry(this,'🎶')">` : '🎶'}</div>` +
`<div class="lib-text"><div class="lib-name">${esc(pl.name)}</div>` +
`<div class="lib-sub">${pl.songCount || 0} Titel${von ? ' · ' + esc(von) : ''}</div></div>`;
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 = '<div class="lib-empty">Playlists nicht geladen.</div>';
}
}
/**
* Tauscht das Cover EINER Zeile aus, nachdem es gerade hochgeladen oder entfernt wurde.
* Noetig, weil die Cover-Adresse dieselbe bleibt (`pl-<id>`): 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 ? `<img src="${cv}" onerror="_imgRetry(this,'🎶')">` : '🎶';
}
/** 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 ? `<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 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 = `<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');
// 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 = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; 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 = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// 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 = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\'no-art\'>🎤</div>'">`;
};
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 = '<div class="card-art"><div class="no-art">→</div></div>'
+ '<div class="card-info"><div class="card-title">Alle anzeigen</div>'
+ `<div class="card-sub">+${sortiert.length - zeigen} weitere</div></div>`;
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 = '<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>`; }
}