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, nativeInsertAt } 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))); } // ─── SETTINGS ─── export function openSettings() { // Lazy import to avoid circular dep — abs.js imports views.js import('./abs.js').then(m => m.absOpenSettings()); 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 || ''; document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount); _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 => `
${esc(t.name)}
`).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(/\/$/,''); S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on'); 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: () => { 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: '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 = `${item.icon}${item.label}`; 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 = '
Lädt…
'; 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 = '
Noch keine Playlists vorhanden
'; return; } S.cachedPlaylists.forEach(pl => { const row = document.createElement('div'); row.className = 'pl-row'; row.innerHTML = ` ${esc(pl.name)}${pl.songCount} Titel`; row.onclick = () => addSongsToPlaylist(pl.id, pl.name); list.appendChild(row); }); } catch(e) { list.innerHTML = `
${e.message}
`; } } 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`); 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 = '
Füge Songs später per Rechtsklick hinzu.
'; 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 ? `` : '
🎵
'; 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]) => `
${l}${esc(String(v))}
` ).join(''); } export async function showSongInfo(s) { S.infoSong = s; S._infoArtData = null; document.getElementById('info-modal').classList.add('open'); _leaveEditMode(); _renderInfoModal(s); try { const r = await api('getSong', `&id=${s.id}`); const full = r.song; if (full) { S.infoSong = full; _renderInfoModal(full); } } catch {} } export function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); _leaveEditMode(); S.infoSong = null; S._infoArtData = null; } function _leaveEditMode() { document.getElementById('info-view-mode').style.display = ''; document.getElementById('info-edit-mode').style.display = 'none'; document.getElementById('info-edit-toggle').classList.remove('active'); document.getElementById('info-edit-status').textContent = ''; } export function toggleInfoEdit() { const editEl = document.getElementById('info-edit-mode'); const viewEl = document.getElementById('info-view-mode'); const btn = document.getElementById('info-edit-toggle'); const isEdit = editEl.style.display !== 'none'; if (isEdit) { _leaveEditMode(); return; } const s = S.infoSong; if (!s) return; editEl.style.display = ''; viewEl.style.display = 'none'; btn.classList.add('active'); // Populate fields from current song document.getElementById('ie-title').value = s.title || ''; document.getElementById('ie-artist').value = s.artist || ''; document.getElementById('ie-albumartist').value = s.albumArtist || ''; document.getElementById('ie-album').value = s.album || ''; document.getElementById('ie-genre').value = s.genre || ''; document.getElementById('ie-year').value = s.year || ''; document.getElementById('ie-track').value = s.track || ''; document.getElementById('ie-disc').value = s.discNumber || ''; document.getElementById('ie-totaltracks').value = s.albumSongCount || ''; // Art preview — use existing cover const cv = coverUrl(s.coverArt, 200); const prev = document.getElementById('info-edit-art-preview'); prev.innerHTML = cv ? `` : '
🎵
'; S._infoArtData = null; } export function onInfoArtFile(input) { const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { const dataUrl = e.target.result; const base64 = dataUrl.split(',')[1]; S._infoArtData = { base64, mime: file.type }; document.getElementById('info-edit-art-preview').innerHTML = ``; }; reader.readAsDataURL(file); } export async function autofillMusicBrainz() { if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } const s = S.infoSong; if (!s) return; const artist = document.getElementById('ie-artist').value || s.artist || ''; const title = document.getElementById('ie-title').value || s.title || ''; const status = document.getElementById('info-edit-status'); status.textContent = '🔍 MusicBrainz…'; try { const r = await fetch(`${S.cfg.backendUrl}/api/musicbrainz/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); const d = await r.json(); if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; } if (d.title) document.getElementById('ie-title').value = d.title; if (d.artist) document.getElementById('ie-artist').value = d.artist; if (d.album) document.getElementById('ie-album').value = d.album; if (d.genre) document.getElementById('ie-genre').value = d.genre; if (d.year) document.getElementById('ie-year').value = d.year; if (d.trackNum) document.getElementById('ie-track').value = d.trackNum; if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks; // Offer cover art from Cover Art Archive if (d.coverUrl) { status.textContent = '✅ Metadaten gefunden — Cover wird geladen…'; // Store URL for save; show preview (CAA redirects, use as img src) S._infoArtData = { url: d.coverUrl }; document.getElementById('info-edit-art-preview').innerHTML = ``; status.textContent = '✅ Metadaten + Cover von MusicBrainz'; } else { status.textContent = '✅ Metadaten von MusicBrainz (kein Cover)'; } } catch (e) { status.textContent = '❌ Fehler: ' + e.message; } } export async function saveMetadata() { if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } const s = S.infoSong; if (!s?.id) return; const status = document.getElementById('info-edit-status'); status.textContent = '💾 Speichern…'; const body = { ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id, title: document.getElementById('ie-title').value || undefined, artist: document.getElementById('ie-artist').value || undefined, albumArtist: document.getElementById('ie-albumartist').value || undefined, album: document.getElementById('ie-album').value || undefined, genre: document.getElementById('ie-genre').value || undefined, year: parseInt(document.getElementById('ie-year').value) || undefined, trackNum: parseInt(document.getElementById('ie-track').value) || undefined, totalTracks: parseInt(document.getElementById('ie-totaltracks').value) || undefined, discNum: parseInt(document.getElementById('ie-disc').value) || undefined, }; if (S._infoArtData?.base64) { body.artworkBase64 = S._infoArtData.base64; body.artworkMime = S._infoArtData.mime || 'image/jpeg'; } else if (S._infoArtData?.url) { body.artworkUrl = S._infoArtData.url; } try { const r = await fetch(`${S.cfg.backendUrl}/api/edit-metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); status.textContent = '✅ Gespeichert! Navidrome scannt…'; toast('✅ Metadaten gespeichert'); setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500); } catch (e) { status.textContent = '❌ Fehler: ' + e.message; } } // ─── 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 ? `` : ''}
${esc(track.trackName||track.collectionName||'—')}
${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}
`; 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, durationMs: S.downloadTarget.trackTimeMillis || undefined, }; addJob(jobId, payload.title, payload.artist, payload.type, payload.album); 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) { // Track last known status separately — job is deleted from S.jobs 5s after completion, // so S.jobs[jobId] may already be gone by the time we check after the loop breaks. let lastStatus = 'pending'; while (true) { await new Promise(r => setTimeout(r, 2500)); const job = S.jobs[jobId]; if (job) lastStatus = job.status; if (!job || job.status === 'done' || job.status === 'error') break; } if (lastStatus !== 'done') return; // Trigger Navidrome library scan so newly downloaded files are indexed before searching. // Initial 3s delay lets the scan actually start before we poll. // 5s buffer after completion lets DB writes settle before we search. // If scan API fails entirely, fall back to a 30s fixed wait. try { await api('startScan', ''); toast('🔍 Navidrome scannt Bibliothek…'); await new Promise(r => setTimeout(r, 3000)); for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 2000)); const st = await api('getScanStatus', ''); if (!st?.scanStatus?.scanning) break; } await new Promise(r => setTimeout(r, 3000)); } catch { await new Promise(r => setTimeout(r, 20000)); } await _createNdPlaylist(tracks, playlistName); } async function _createNdPlaylist(tracks, name) { try { toast('🎵 Erstelle Navidrome-Playlist…'); const songIds = []; const missing = []; const norm = s => s?.toLowerCase().trim() || ''; // Strip non-word, non-space chars for loose artist first-word comparison const normA = s => norm(s).replace(/[^\w\s]/g, ''); for (const t of tracks) { if (!t.trackName) continue; const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName; try { const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : ''; const artistQuery = firstArtist ? firstArtist + ' ' + t.trackName : t.trackName; const r = await api('search3', '&query=' + encodeURIComponent(artistQuery) + '&songCount=20&albumCount=0&artistCount=0'); let songs = r.searchResult3?.song || []; // If artist+title query returned no title match (e.g. special chars in artist like 'Kløn'), // retry with title-only so the track can still be found if (!songs.some(s => norm(s.title) === norm(t.trackName))) { try { const r2 = await api('search3', '&query=' + encodeURIComponent(t.trackName) + '&songCount=20&albumCount=0&artistCount=0'); songs = r2.searchResult3?.song || []; } catch { /* keep original songs */ } } const firstWord = s => normA(s).split(/\s+/)[0]; const titleMatch = s => norm(s.title) === norm(t.trackName); const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName); const match = songs.find(s => titleMatch(s) && artistMatch(s)) || songs.find(s => titleMatch(s)); if (match) songIds.push(match.id); else missing.push(missingLabel); } catch { missing.push(missingLabel); } } 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(); _renderNotifList(); } 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 = '
Keine Benachrichtigungen
'; 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 ? `
${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen
` : ''; const missingHtml = n.missing?.length ? `
${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden
` : ''; const hasNav = n.status === 'done' && ((n.navType === 'playlist' && n.navId) || n.navArtist || n.navAlbum); const navLabel = n.navType === 'artist' ? 'Zum Künstler →' : (n.navType === 'track' || n.navType === 'album') ? 'Zum Album →' : 'Öffnen →'; const navBtn = hasNav ? `` : ''; return `
${icon}
${esc(n.title)}
${n.artist ? esc(n.artist) + ' · ' : ''}${time}
${n.message ? `
${esc(n.message)}
` : ''} ${failedHtml} ${missingHtml} ${navBtn}
`; }).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; } const norm = s => s?.toLowerCase().trim() || ''; // Track or album download → find album in Navidrome and navigate there. if (n.navType === 'track' || n.navType === 'album') { const albumQuery = n.navAlbum || n.navTitle; if (albumQuery) { try { const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`); const albums = r.searchResult3?.album || []; // Prefer exact album + artist match, fall back to album-name-only match. const match = albums.find(a => norm(a.name) === norm(albumQuery) && norm(a.artist) === norm(n.navArtist)) || albums.find(a => norm(a.name) === norm(albumQuery)) || albums[0]; if (match) { navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' }); return; } } catch {} } toast('Album nicht gefunden — Library-Scan nötig?'); return; } if (n.navArtist) { try { 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, album) { S.jobs[id] = {title, artist, type: type || '', album: album || '', 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'); const activeIds = new Set(Object.keys(S.jobs)); // Remove cards whose jobs are gone panel.querySelectorAll('[data-job-id]').forEach(el => { if (!activeIds.has(el.dataset.jobId)) el.remove(); }); Object.entries(S.jobs).forEach(([id, j]) => { let c = panel.querySelector(`[data-job-id="${id}"]`); if (!c) { c = document.createElement('div'); c.className = 'job-card'; c.dataset.jobId = id; panel.appendChild(c); } const isDone = j.status === 'done', isErr = j.status === 'error'; const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…'; c.innerHTML = `
${esc(j.title)}
${esc(j.artist)}
${statusText}
`; }); } 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 || '', navAlbum: job?.album || '', 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 ? `` : '
🎶
'; 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 = ``; 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 = ``; document.getElementById('edit-pl-cover').innerHTML = ``; }, 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 ? `` : '
🎶
'; } } 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 = '
🎶
'; document.getElementById('playlist-cover').innerHTML = '
🎶
'; } 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); } }