Files
NovaSonic/src/ui.js
T

671 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, ndAuth } from './api.js';
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
// Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon } from './player.js';
import { navigate, loadPlaylists } from './views.js';
// ─── SIDEBAR ───
export function openSidebar() {
document.getElementById('sidebar').classList.add('mobile-open');
document.getElementById('mobile-overlay').classList.add('active');
}
export function closeSidebar() {
document.getElementById('sidebar').classList.remove('mobile-open');
document.getElementById('mobile-overlay').classList.remove('active');
}
export function switchNavMode(mode) {
const lib = document.getElementById('bnav-library');
const disc = document.getElementById('bnav-discover');
if(!lib || !disc) return;
lib.style.display = mode==='library' ? 'flex' : 'none';
disc.style.display = mode==='discover' ? 'flex' : 'none';
document.getElementById('bnav-tab-library')?.classList.toggle('active', mode==='library');
document.getElementById('bnav-tab-discover')?.classList.toggle('active', mode==='discover');
}
// ─── MOBILE SEARCH ───
export function toggleMobileSearch() {
const bar = document.getElementById('mobile-search-bar');
const btn = document.getElementById('mobile-search-btn');
const open = bar.style.display==='none' || bar.style.display==='';
bar.style.display = open ? 'block' : 'none';
btn.style.color = open ? 'var(--accent)' : 'var(--text2)';
if(open) { setTimeout(() => document.getElementById('mobile-search-input').focus(), 50); }
else { closeMobileSearch(); }
}
export function closeMobileSearch() {
const bar = document.getElementById('mobile-search-bar');
const inp = document.getElementById('mobile-search-input');
const btn = document.getElementById('mobile-search-btn');
bar.style.display = 'none';
btn.style.color = 'var(--text2)';
inp.value = '';
navigate('home');
}
// ─── SETUP ───
export function switchSetupTab(t) {
document.querySelectorAll('.setup-tab').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
document.querySelectorAll('.setup-pane').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
}
export function showSetupError(m) {
const el = document.getElementById('setup-error');
el.textContent = m; el.style.display = 'block';
}
// ─── SETTINGS ───
export function openSettings() {
document.getElementById('s-url').value = S.cfg.url || '';
document.getElementById('s-user').value = S.cfg.user || '';
document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open');
checkBackend();
}
function _renderThemePicker() {
const el = document.getElementById('theme-picker');
if (!el) return;
const current = getCurrentThemeId();
el.innerHTML = THEMES.map(t => `
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
<div class="theme-swatch-dot"></div>
<span>${esc(t.name)}</span>
</div>`).join('');
}
export function _pickTheme(id) {
applyTheme(id);
_renderThemePicker();
}
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
export async function confirmDisconnect() {
const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden');
if (ok) { closeSettings(); window.disconnect(); }
}
export function saveSettings() {
const pass = document.getElementById('s-pass').value;
S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,'');
S.cfg.user = document.getElementById('s-user').value.trim();
if(pass) S.cfg.pass = pass;
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert');
}
export async function checkBackend() {
const dot = document.getElementById('backend-status-dot');
const txt = document.getElementById('backend-status-text');
const url = document.getElementById('s-backend').value.trim() || S.cfg.backendUrl;
if(!url) { dot.className='status-dot'; txt.textContent='Keine URL konfiguriert'; return; }
dot.className='status-dot'; txt.textContent='Verbinde…';
try {
const r = await fetch(`${url}/api/health`, {signal: AbortSignal.timeout(4000)});
const d = await r.json();
dot.className='status-dot ok';
txt.textContent = `Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`;
} catch(e) { dot.className='status-dot err'; txt.textContent='Nicht erreichbar: '+e.message; }
}
// ─── 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: () => {
S.queue.splice(S.qIdx+1, 0, {...song});
renderQueue(); toast('In Warteschlange eingefügt');
}},
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
S.queue.push({...song}); 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: 'Song-Info', fn: () => 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');
m.style.display='none'; m.innerHTML='';
}
document.addEventListener('click', e => {
if(!document.getElementById('ctx-menu').contains(e.target)) hideCtxMenu();
});
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';
row.innerHTML = `<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>
<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) {
closePlModal();
try {
const ids = S.plModalSongs.map(s => `&songIdToAdd=${s.id}`).join('');
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
toast(`✅ ${S.plModalSongs.length===1 ? S.plModalSongs[0].title : S.plModalSongs.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`);
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();
});
// ─── SONG INFO MODAL ───
function _renderInfoModal(s) {
const cv = coverUrl(s.coverArt, 200);
document.getElementById('info-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:32px">🎵</div>';
document.getElementById('info-top-title').textContent = s.title || '—';
document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : '');
const fields = [
['Titel', s.title],
['Künstler', s.artist],
['Album', s.album],
['Jahr', s.year],
['Track #', s.track],
['Disc #', s.discNumber],
['Genre', s.genre],
['Dauer', formatDur(s.duration)],
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
['Format', s.suffix?.toUpperCase()],
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
['Pfad', s.path],
['Plays', s.playCount],
].filter(([,v]) => v!=null && v!=='' && v!==undefined);
document.getElementById('info-rows').innerHTML = fields.map(([l,v]) =>
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
).join('');
}
export async function showSongInfo(s) {
document.getElementById('info-modal').classList.add('open');
_renderInfoModal(s);
try {
const r = await api('getSong', `&id=${s.id}`);
const full = r.song;
if (full) _renderInfoModal(full);
} catch {}
}
export function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); }
document.addEventListener('click', e => {
const m = document.getElementById('info-modal');
if(m.classList.contains('open') && e.target===m) closeInfoModal();
});
// ─── DOWNLOAD MODAL ───
export function openDlModal(track) {
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
S.downloadTarget = track; S.dlType = 'track';
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track'));
const info = document.getElementById('dl-modal-info');
const art = track.artworkUrl100 || track.artworkUrl60 || '';
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen';
document.getElementById('download-modal').classList.add('open');
}
export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); }
export function selectDlType(el, type) {
S.dlType = type;
document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
}
export async function executeDownload() {
if(!S.downloadTarget || !S.cfg.backendUrl) return;
closeDlModal();
const jobId = 'job_' + Date.now();
const payload = {
type: S.dlType,
artist: S.downloadTarget.artistName || 'Unknown Artist',
title: S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown',
album: S.downloadTarget.collectionName || '',
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit',
trackNum: S.downloadTarget.trackNumber || undefined,
totalTracks: S.downloadTarget.trackCount || undefined,
discNum: S.downloadTarget.discNumber || undefined,
discTotal: S.downloadTarget.discCount || undefined,
};
addJob(jobId, payload.title, payload.artist, payload.type);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...payload, jobId}),
});
if(!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
} catch(e) {
updateJob(jobId, 'error', 'Verbindung zum Backend fehlgeschlagen');
toast('❌ Backend nicht erreichbar: '+e.message);
}
}
// ─── SPOTIFY DOWNLOAD ───
export async function executeSpotifyDownload({ type, title, artist, artworkUrl, tracks, createPlaylist, playlistName }) {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const jobId = 'job_' + Date.now();
addJob(jobId, title, artist, type);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
if (createPlaylist && playlistName && tracks?.length) {
_pollAndCreatePlaylist(jobId, tracks, playlistName);
}
} catch(e) {
updateJob(jobId, 'error', 'Backend-Verbindung fehlgeschlagen: ' + e.message);
toast('❌ Backend nicht erreichbar: ' + e.message);
}
}
async function _pollAndCreatePlaylist(jobId, tracks, playlistName) {
while (true) {
await new Promise(r => setTimeout(r, 2500));
const job = S.jobs[jobId];
if (!job || job.status === 'done' || job.status === 'error') break;
}
if (S.jobs[jobId]?.status !== 'done') return;
await _createNdPlaylist(tracks, playlistName);
}
async function _createNdPlaylist(tracks, name) {
try {
toast('🎵 Erstelle Navidrome-Playlist…');
const songIds = [];
const missing = [];
const norm = s => s?.toLowerCase().trim() || '';
for (const t of tracks) {
if (!t.trackName) continue;
try {
const r = await api('search3', `&query=${encodeURIComponent(t.trackName)}&songCount=5&albumCount=0&artistCount=0`);
const songs = r.searchResult3?.song || [];
const match = songs.find(s =>
norm(s.title) === norm(t.trackName) &&
norm(s.artist).split(' ')[0] === norm(t.artistName).split(' ')[0]
) || songs.find(s => norm(s.title) === norm(t.trackName));
if (match) songIds.push(match.id);
else missing.push(t.trackName);
} catch { missing.push(t.trackName); }
}
if (!songIds.length) {
toast('⚠ Keine Tracks in Navidrome gefunden — zuerst Library scannen, dann Playlist manuell anlegen');
_addNotif({ id: 'pl_' + Date.now(), title: `Playlist: ${name}`, artist: '', status: 'error', message: 'Keine Tracks in Navidrome gefunden — Library scan nötig', missing, timestamp: Date.now(), read: false });
return;
}
const songParams = songIds.map(id => `&songId=${encodeURIComponent(id)}`).join('');
const plRes = await api('createPlaylist', `&name=${encodeURIComponent(name)}${songParams}`);
const plId = plRes.playlist?.id || null;
const msg = `${songIds.length}/${tracks.length} Tracks hinzugefügt${missing.length ? `, ${missing.length} nicht gefunden` : ''}`;
toast(`✅ Playlist "${name}" mit ${songIds.length}/${tracks.length} Tracks erstellt`);
_addNotif({ id: 'pl_' + Date.now(), title: `Playlist: ${name}`, artist: '', status: 'done', message: msg, missing, timestamp: Date.now(), read: false, navType: 'playlist', navId: plId, navName: name });
} catch(e) {
toast('Playlist-Erstellung fehlgeschlagen: ' + e.message);
}
}
// ─── NOTIFICATIONS ───
const _NOTIF_KEY = 'ns_notifications';
function _loadNotifs() {
try { return JSON.parse(localStorage.getItem(_NOTIF_KEY) || '[]'); } catch { return []; }
}
function _saveNotifs(n) { localStorage.setItem(_NOTIF_KEY, JSON.stringify(n.slice(0, 100))); }
function _addNotif(notif) {
const list = _loadNotifs();
list.unshift(notif);
_saveNotifs(list);
_renderNotifBell();
}
export function deleteNotif(id) {
_saveNotifs(_loadNotifs().filter(n => n.id !== id));
_renderNotifBell();
_renderNotifList();
}
export function clearNotifs() {
_saveNotifs([]);
_renderNotifBell();
_renderNotifList();
}
export function toggleNotifPanel() {
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
if (!panel) return;
const isOpen = panel.classList.contains('open');
if (isOpen) {
panel.classList.remove('open');
overlay?.classList.remove('active');
} else {
_saveNotifs(_loadNotifs().map(n => ({...n, read: true})));
_renderNotifList();
_renderNotifBell();
panel.classList.add('open');
overlay?.classList.add('active');
}
}
export function initNotifBell() { _renderNotifBell(); }
function _renderNotifBell() {
const unread = _loadNotifs().filter(n => !n.read).length;
document.querySelectorAll('.notif-badge').forEach(b => {
b.textContent = unread > 9 ? '9+' : unread;
b.style.display = unread > 0 ? '' : 'none';
});
}
function _renderNotifList() {
const list = document.getElementById('notif-list');
if (!list) return;
const notifs = _loadNotifs();
if (!notifs.length) {
list.innerHTML = '<div class="notif-empty">Keine Benachrichtigungen</div>';
return;
}
list.innerHTML = notifs.map(n => {
const d = new Date(n.timestamp);
const time = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
const icon = n.status === 'done' ? '✅' : '❌';
const failedHtml = n.failed?.length
? `<details class="notif-failed"><summary>${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen</summary><ul>${n.failed.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const missingHtml = n.missing?.length
? `<details class="notif-failed"><summary>${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden</summary><ul>${n.missing.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const hasNav = n.status === 'done' && ((n.navType === 'playlist' && n.navId) || n.navArtist);
const navBtn = hasNav
? `<button class="notif-nav-btn" onclick="_notifNavigate('${esc(n.id)}')">Öffnen →</button>`
: '';
return `<div class="notif-card${n.read ? '' : ' unread'}">
<div class="notif-card-top">
<span class="notif-icon">${icon}</span>
<div class="notif-info">
<div class="notif-title">${esc(n.title)}</div>
<div class="notif-sub">${n.artist ? esc(n.artist) + ' · ' : ''}${time}</div>
</div>
<button class="notif-x" onclick="deleteNotif('${esc(n.id)}')">✕</button>
</div>
${n.message ? `<div class="notif-msg">${esc(n.message)}</div>` : ''}
${failedHtml}
${missingHtml}
${navBtn}
</div>`;
}).join('');
}
export async function _notifNavigate(notifId) {
const notifs = _loadNotifs();
const n = notifs.find(x => x.id === notifId);
if (!n) return;
// close panel first
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
panel?.classList.remove('open');
overlay?.classList.remove('active');
if (n.navType === 'playlist' && n.navId) {
navigate('playlist-detail', { id: n.navId, name: n.navName || n.title.replace(/^Playlist:\s*/, '') });
return;
}
if (n.navArtist) {
try {
const norm = s => s?.toLowerCase().trim() || '';
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
const artists = r.searchResult3?.artist || [];
const match = artists.find(a => norm(a.name) === norm(n.navArtist)) || artists[0];
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
} catch {}
toast('Künstler nicht gefunden — Library-Scan nötig?');
}
}
function _parseFailedTracks(log) {
const re = /Fehler bei "([^"]+)"/;
return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean);
}
// ─── JOBS ───
function addJob(id, title, artist, type) {
S.jobs[id] = {title, artist, type: type || '', progress:0, status:'pending'};
renderJobs();
}
function updateJob(id, status, msg, progress) {
if(!S.jobs[id]) return;
S.jobs[id] = {...S.jobs[id], status, msg:msg||'', progress: progress ?? S.jobs[id].progress};
renderJobs();
if(status==='done') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 5000);
if(status==='error') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 8000);
}
function renderJobs() {
const panel = document.getElementById('jobs-panel');
panel.innerHTML = '';
Object.entries(S.jobs).forEach(([id,j]) => {
const c = document.createElement('div'); c.className = 'job-card';
const isDone=j.status==='done', isErr=j.status==='error';
const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…';
c.innerHTML = `<div class="job-header">
<div class="job-title">${esc(j.title)}</div>
<div class="job-status">${esc(j.artist)}</div>
</div>
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
<div class="job-sub">${statusText}</div>`;
panel.appendChild(c);
});
}
async function pollJob(jobId) {
if(!S.jobs[jobId]) return;
try {
const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`);
const d = await res.json();
updateJob(jobId, d.status, d.message, d.progress);
if(d.status !== 'done' && d.status !== 'error') {
setTimeout(() => pollJob(jobId), 1500);
} else {
const job = S.jobs[jobId];
const title = job?.title || 'Download';
_addNotif({
id: jobId,
title,
artist: job?.artist || '',
status: d.status,
message: d.message || (d.status === 'done' ? 'Fertig!' : 'Fehler'),
failed: _parseFailedTracks(d.log),
timestamp: Date.now(),
read: false,
navType: job?.type || '',
navArtist: job?.artist || '',
navTitle: title,
});
if (d.status === 'done') {
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
}
}
} catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); }
}
// ─── 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');
} 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">`;
}, 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>';
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
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; }
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; });
}
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`);
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}