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>
250 lines
13 KiB
JavaScript
250 lines
13 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast } from '../utils.js';
|
||
import { api, coverUrl, ndAuth, invalidateApiCache } from '../api.js';
|
||
// Circular — safe (only used inside function bodies)
|
||
import { playQueue, dlSongLocal, nativeInsertAt, renderQueue } from '../player/player.js';
|
||
import { navigate, loadPlaylists, loadPlaylistDetail, renderSidebarPlaylists, bustSidebarCover } from '../views/views.js';
|
||
|
||
// ─── CONTEXT MENU ───
|
||
export function showCtxMenu(e, song, list, idx) {
|
||
const menu = document.getElementById('ctx-menu');
|
||
menu.innerHTML = '';
|
||
const items = [
|
||
{ icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) },
|
||
{ icon: '+', label: 'Als Nächstes einfügen', fn: () => {
|
||
const insertIdx = S.qIdx + 1;
|
||
S.queue.splice(insertIdx, 0, {...song});
|
||
nativeInsertAt(song, insertIdx);
|
||
renderQueue(); toast('In Warteschlange eingefügt');
|
||
}},
|
||
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
|
||
S.queue.push({...song});
|
||
nativeInsertAt(song, -1);
|
||
renderQueue(); toast('Zur Queue hinzugefügt');
|
||
}},
|
||
{ sep: true },
|
||
{ icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) },
|
||
{ icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => {
|
||
api(song._starred ? 'unstar' : 'star', `&id=${song.id}`)
|
||
.then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); })
|
||
.catch(err => toast('Fehler: ' + err.message));
|
||
}},
|
||
{ sep: true },
|
||
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
|
||
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
|
||
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
|
||
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
|
||
];
|
||
items.forEach(item => {
|
||
if(item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; }
|
||
const el = document.createElement('div'); el.className = 'ctx-item';
|
||
el.innerHTML = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
|
||
el.onclick = () => { hideCtxMenu(); item.fn(); };
|
||
menu.appendChild(el);
|
||
});
|
||
const vw=window.innerWidth, vh=window.innerHeight;
|
||
let x=e.clientX, y=e.clientY;
|
||
menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block';
|
||
const w=menu.offsetWidth, h=menu.offsetHeight;
|
||
if(x+w>vw-8) x=vw-w-8;
|
||
if(y+h>vh-8) y=vh-h-8;
|
||
menu.style.left=x+'px'; menu.style.top=y+'px';
|
||
}
|
||
export function hideCtxMenu() {
|
||
const m = document.getElementById('ctx-menu');
|
||
if (!m) return;
|
||
m.style.display='none'; m.innerHTML='';
|
||
}
|
||
document.addEventListener('click', e => {
|
||
const m = document.getElementById('ctx-menu');
|
||
if (m && !m.contains(e.target)) hideCtxMenu();
|
||
});
|
||
document.addEventListener('scroll', () => {
|
||
const m = document.getElementById('ctx-menu');
|
||
if (m && m.style.display !== 'none') hideCtxMenu();
|
||
}, { passive: true, capture: true });
|
||
document.addEventListener('touchmove', () => {
|
||
const m = document.getElementById('ctx-menu');
|
||
if (m && m.style.display !== 'none') hideCtxMenu();
|
||
}, { passive: true, capture: true });
|
||
document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); });
|
||
|
||
// ─── PLAYLIST MODAL (Add to / Create) ───
|
||
export async function openPlModal(songs) {
|
||
S.plModalSongs = songs;
|
||
document.getElementById('pl-modal-title').textContent =
|
||
songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
|
||
document.getElementById('pl-new-name').value = '';
|
||
const list = document.getElementById('pl-list');
|
||
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
|
||
document.getElementById('pl-modal').classList.add('open');
|
||
try {
|
||
const r = await api('getPlaylists');
|
||
S.cachedPlaylists = r.playlists?.playlist || [];
|
||
list.innerHTML = '';
|
||
if(!S.cachedPlaylists.length) {
|
||
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
|
||
return;
|
||
}
|
||
S.cachedPlaylists.forEach(pl => {
|
||
const row = document.createElement('div'); row.className = 'pl-row';
|
||
const cv = pl.coverArt ? coverUrl(pl.coverArt, 96) : null;
|
||
const inner = cv
|
||
? `<img src="${cv}" alt="" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">`
|
||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>';
|
||
row.innerHTML = `<span style="width:34px;height:34px;border-radius:6px;flex-shrink:0;background:var(--surface2);display:flex;align-items:center;justify-content:center;overflow:hidden;color:var(--text3)">${inner}</span>
|
||
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
|
||
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
|
||
list.appendChild(row);
|
||
});
|
||
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
|
||
}
|
||
export function closePlModal() {
|
||
document.getElementById('pl-modal').classList.remove('open');
|
||
S.plModalSongs = [];
|
||
}
|
||
export async function addSongsToPlaylist(plId, plName) {
|
||
const songs = [...S.plModalSongs];
|
||
closePlModal();
|
||
try {
|
||
const ids = songs.map(s => `&songIdToAdd=${s.id}`).join('');
|
||
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
|
||
toast(`✅ ${songs.length===1 ? songs[0].title : songs.length+' Songs'} → ${plName}`);
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
export async function createAndAddPlaylist() {
|
||
const name = document.getElementById('pl-new-name').value.trim();
|
||
if(!name) { document.getElementById('pl-new-name').focus(); return; }
|
||
closePlModal();
|
||
try {
|
||
const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join('');
|
||
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
|
||
toast(`✅ Playlist "${name}" erstellt`);
|
||
invalidateApiCache('getPlaylists'); // sonst zeigt die Seitenleiste die neue Liste 3 Min. nicht
|
||
renderSidebarPlaylists();
|
||
if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
export function createPlaylistPrompt() {
|
||
document.getElementById('pl-new-name').value = '';
|
||
S.plModalSongs = [];
|
||
document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen';
|
||
document.getElementById('pl-list').innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
|
||
document.getElementById('pl-modal').classList.add('open');
|
||
}
|
||
document.addEventListener('click', e => {
|
||
const m = document.getElementById('pl-modal');
|
||
if(m.classList.contains('open') && e.target===m) closePlModal();
|
||
});
|
||
|
||
// ─── CONFIRM DIALOG ───
|
||
let _confirmResolve = null;
|
||
export function _confirmOk() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(true); _confirmResolve = null; }
|
||
export function _confirmCancel() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(false); _confirmResolve = null; }
|
||
|
||
export function showConfirm(title, msg, okLabel = 'Bestätigen') {
|
||
document.getElementById('confirm-title').textContent = title;
|
||
document.getElementById('confirm-msg').textContent = msg;
|
||
document.getElementById('confirm-ok-btn').textContent = okLabel;
|
||
document.getElementById('confirm-overlay').classList.add('open');
|
||
return new Promise(resolve => { _confirmResolve = resolve; });
|
||
}
|
||
|
||
// ─── EDIT PLAYLIST MODAL ───
|
||
export function openEditPlModal(pl) {
|
||
const overlay = document.getElementById('edit-pl-overlay');
|
||
document.getElementById('edit-pl-name').value = pl.name || '';
|
||
document.getElementById('edit-pl-desc').value = pl.comment || '';
|
||
const tog = document.getElementById('edit-pl-public-toggle');
|
||
tog.classList.toggle('on', !!pl.public);
|
||
const cv = coverUrl(pl.coverArt, 300);
|
||
document.getElementById('edit-pl-cover').innerHTML = cv
|
||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||
overlay.classList.add('open');
|
||
}
|
||
export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); }
|
||
export async function saveEditPlaylist() {
|
||
const pl = S._currentPlaylist; if(!pl) return;
|
||
const name = document.getElementById('edit-pl-name').value.trim();
|
||
if(!name) { toast('Name darf nicht leer sein'); return; }
|
||
const comment = document.getElementById('edit-pl-desc').value.trim();
|
||
const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on');
|
||
const btn = document.querySelector('.edit-pl-btn-save');
|
||
btn.disabled=true; btn.textContent='Speichern…';
|
||
try {
|
||
await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
|
||
S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub};
|
||
document.getElementById('playlist-detail-name').textContent = name;
|
||
const descEl = document.getElementById('playlist-detail-desc');
|
||
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
|
||
closeEditPlModal();
|
||
toast('Playlist aktualisiert');
|
||
invalidateApiCache('getPlaylists'); // Name/Sichtbarkeit stehen auch in der Seitenleiste
|
||
renderSidebarPlaylists();
|
||
} catch(e) { toast('Fehler: '+e.message); }
|
||
finally { btn.disabled=false; btn.textContent='Speichern'; }
|
||
}
|
||
export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); }
|
||
export async function editPlCoverSelected(input) {
|
||
const file = input.files?.[0]; if(!file || !S._currentPlaylist) return;
|
||
input.value = '';
|
||
const preview = URL.createObjectURL(file);
|
||
document.getElementById('edit-pl-cover').innerHTML = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
|
||
try {
|
||
const token = await ndAuth();
|
||
const form = new FormData(); form.append('image', file, file.name);
|
||
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
|
||
method: 'POST',
|
||
headers: {'X-Nd-Authorization': 'Bearer '+token},
|
||
body: form,
|
||
});
|
||
if(!res.ok) throw new Error(res.status);
|
||
toast('Cover aktualisiert');
|
||
setTimeout(() => {
|
||
const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now();
|
||
const detailImg = document.getElementById('playlist-cover');
|
||
if(detailImg) detailImg.innerHTML = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
|
||
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
|
||
bustSidebarCover(S._currentPlaylist.id, S._currentPlaylist.coverArt);
|
||
}, 800);
|
||
} catch(e) {
|
||
S._ndToken = null;
|
||
toast('Cover-Upload fehlgeschlagen: '+e.message);
|
||
const cv = coverUrl(S._currentPlaylist.coverArt, 300);
|
||
document.getElementById('edit-pl-cover').innerHTML = cv
|
||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||
}
|
||
}
|
||
export async function editPlRemoveCover() {
|
||
if(!S._currentPlaylist) return;
|
||
try {
|
||
const token = await ndAuth();
|
||
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
|
||
method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token},
|
||
});
|
||
if(!res.ok) throw new Error(res.status);
|
||
toast('Cover entfernt');
|
||
S._currentPlaylist.coverArt = null;
|
||
document.getElementById('edit-pl-cover').innerHTML = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
|
||
bustSidebarCover(S._currentPlaylist?.id, null);
|
||
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
|
||
}
|
||
|
||
export async function deletePlaylist() {
|
||
const pl = S._currentPlaylist;
|
||
if (!pl) return;
|
||
const ok = await showConfirm('Playlist löschen', `"${pl.name}" wird unwiderruflich gelöscht.`, 'Löschen');
|
||
if (!ok) return;
|
||
try {
|
||
await api('deletePlaylist', `&id=${pl.id}`);
|
||
closeEditPlModal();
|
||
toast(`🗑 "${pl.name}" gelöscht`);
|
||
invalidateApiCache('getPlaylists'); // drop the 3-min cache so the list reloads without the deleted playlist
|
||
renderSidebarPlaylists();
|
||
navigate('playlists');
|
||
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
|
||
}
|