From 0663b1c9237b3d10c70b451cc899a738622eafa7 Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 7 Jun 2026 14:00:38 +0200 Subject: [PATCH] refactor: split ui/ui.js into 5 focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.js — settings modal, romaji overrides, sidebar, theme, nav mode modals.js — context menu, playlist-add modal, confirm dialog, edit-playlist modal metadata.js — song info/edit modal, MusicBrainz/iTunes autofill, saveMetadata, deleteSong notify.js — notification panel, bell badge, _addNotif, _notifNavigate downloads.js — download modal, Spotify download, jobs panel, startArtistSortFix ui.js — barrel re-export; all external imports unchanged player.js left unsplit: shared private state (_nativePlaying, _playGen, _queueGen, etc.) spans every section — splitting would require exporting internal state. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/downloads.js | 276 +++++++++++ src/ui/metadata.js | 308 ++++++++++++ src/ui/modals.js | 236 +++++++++ src/ui/notify.js | 146 ++++++ src/ui/settings.js | 153 ++++++ src/ui/ui.js | 1143 +------------------------------------------ 6 files changed, 1125 insertions(+), 1137 deletions(-) create mode 100644 src/ui/downloads.js create mode 100644 src/ui/metadata.js create mode 100644 src/ui/modals.js create mode 100644 src/ui/notify.js create mode 100644 src/ui/settings.js diff --git a/src/ui/downloads.js b/src/ui/downloads.js new file mode 100644 index 0000000..4f881f3 --- /dev/null +++ b/src/ui/downloads.js @@ -0,0 +1,276 @@ +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex } from '../api.js'; +import { _addNotif } from './notify.js'; + +// ─── 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 ov = getRomajiOverrides(); + const _artist = S.downloadTarget.artistName || 'Unknown Artist'; + const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown'; + const _album = S.downloadTarget.collectionName || ''; + const payload = { + type: S.dlType, + artist: _artist, + title: _title, + album: _album, + 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, + ...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }), + ...(ov[_title] && { titleSort: ov[_title] }), + ...(ov[_album] && { albumSort: ov[_album] }), + }; + 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); + const ov = getRomajiOverrides(); + const enrichedTracks = (tracks || []).map(t => { + const a = t.artistName || ''; + const n = t.trackName || ''; + const al = t.collectionName || ''; + return { + ...t, + ...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }), + ...(ov[n] && { titleSort: ov[n] }), + ...(ov[al] && { albumSort: ov[al] }), + }; + }); + 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: enrichedTracks }), + }); + 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); + } +} + +// Used by settings.js for romaji fix-sort job +export async function startArtistSortFix(kanji, romaji) { + if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return; + const jobId = 'job_' + Date.now(); + addJob(jobId, `Romaji-Fix: ${romaji}`, kanji, 'fix-sort'); + const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: kanji, artistSort: romaji, jobId }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + pollJob(jobId); +} + +async function _pollAndCreatePlaylist(jobId, tracks, playlistName) { + 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; + + 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() || ''; + const normA = s => norm(s).replace(/[^\w\s]/g, ''); + const JP_RE = /[぀-ヿ一-鿿]/; + const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName)); + const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null; + const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : ''); + const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles; + const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); + for (const [idx, t] of tracks.entries()) { + 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 (!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); + let match = songs.find(s => titleMatch(s) && artistMatch(s)) + || songs.find(s => titleMatch(s)); + if (!match && romajiIdx && JP_RE.test(t.trackName)) { + const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName)); + if (jpEntry) match = { id: jpEntry.id }; + } + if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) { + const rTitle = romajiTitles[idx]; + try { + const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0'); + const s3 = r3.searchResult3?.song || []; + match = s3.find(s => normR(s.title) === normR(rTitle)) || null; + } catch { /* ignore */ } + } + 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); + } +} + +// ─── 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)); + + 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!`); + if (!d.skipped) invalidateRomajiIndex(); + } + } + } catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); } +} + +function _parseFailedTracks(log) { + const re = /Fehler bei "([^"]+)"/; + return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean); +} diff --git a/src/ui/metadata.js b/src/ui/metadata.js new file mode 100644 index 0000000..fbb7897 --- /dev/null +++ b/src/ui/metadata.js @@ -0,0 +1,308 @@ +import { S } from '../state.js'; +import { esc, formatDur, toast } from '../utils.js'; +import { api, coverUrl, fetchRomajiIndex, romajiLookup, getRomajiOverrides } from '../api.js'; +// Circular — safe (only used inside function bodies) +import { loadPlaylists, loadPlaylistDetail } from '../views/views.js'; +import { showConfirm } from './modals.js'; + +// ─── SONG INFO MODAL ─── +function _fmtArtists(str, sep) { + if (!str) return ''; + return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep); +} + +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-Künstler', s.albumArtist], + ['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) { + if (!full.albumArtist && full.albumId) { + try { + const ar = await api('getAlbum', `&id=${full.albumId}`); + if (ar.album?.artist) full.albumArtist = ar.album.artist; + } catch {} + } + S.infoSong = full; + _renderInfoModal(full); + if (document.getElementById('info-edit-mode').style.display !== 'none') { + document.getElementById('ie-title').value = full.title || ''; + document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || ''; + document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || ''; + document.getElementById('ie-album').value = full.album || ''; + document.getElementById('ie-genre').value = full.genre || ''; + document.getElementById('ie-year').value = full.year || ''; + document.getElementById('ie-track').value = full.track || ''; + document.getElementById('ie-disc').value = full.discNumber || ''; + document.getElementById('ie-totaltracks').value = full.albumSongCount || ''; + } + } + } 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'); + + document.getElementById('ie-title').value = s.title || ''; + document.getElementById('ie-artist').value = _fmtArtists(s.artist, '; ') || ''; + document.getElementById('ie-albumartist').value = _fmtArtists(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 || ''; + + document.getElementById('ie-titlesort').value = ''; + document.getElementById('ie-artistsort').value = ''; + document.getElementById('ie-albumartistsort').value = ''; + document.getElementById('ie-albumsort').value = ''; + + const _sId = s.id; + fetchRomajiIndex().catch(() => null).then(() => { + const rm = romajiLookup(_sId); + if (rm) { + const _jp = /[ぁ-んァ-ン一-龯]/; + if (rm.titleRomaji && !_jp.test(rm.titleRomaji)) document.getElementById('ie-titlesort').value = rm.titleRomaji; + if (rm.artistRomaji && !_jp.test(rm.artistRomaji)) { + document.getElementById('ie-artistsort').value = rm.artistRomaji; + document.getElementById('ie-albumartistsort').value = rm.artistRomaji; + } + if (rm.albumRomaji && !_jp.test(rm.albumRomaji)) document.getElementById('ie-albumsort').value = rm.albumRomaji; + } + if (!S.cfg.backendUrl || !_sId) return null; + const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId }); + return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null); + }).then(t => { + if (t) { + const _jp = /[ぁ-んァ-ン一-龯]/; + if (t.titleSort && !_jp.test(t.titleSort)) document.getElementById('ie-titlesort').value = t.titleSort; + if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort; + if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort; + if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort; + } + const ov = getRomajiOverrides(); + if (s.artist && ov[s.artist]) { document.getElementById('ie-artistsort').value = ov[s.artist]; document.getElementById('ie-albumartistsort').value = ov[s.artist]; } + if (s.albumArtist && ov[s.albumArtist]) document.getElementById('ie-albumartistsort').value = ov[s.albumArtist]; + if (s.title && ov[s.title]) document.getElementById('ie-titlesort').value = ov[s.title]; + if (s.album && ov[s.album]) document.getElementById('ie-albumsort').value = ov[s.album]; + }).catch(() => {}); + + 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; + if (d.coverUrl) { + status.textContent = '✅ Metadaten gefunden — Cover wird geladen…'; + 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 autofillItunes() { + 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 = '🔍 Apple Music…'; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); + const d = await r.json(); + if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; } + 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; + if (d.coverUrl) { + S._infoArtData = { url: d.coverUrl }; + document.getElementById('info-edit-art-preview').innerHTML = + ``; + status.textContent = '✅ Metadaten + Cover von Apple Music'; + } else { + status.textContent = '✅ Metadaten von Apple Music (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, + titleSort: document.getElementById('ie-titlesort').value || undefined, + artistSort: document.getElementById('ie-artistsort').value || undefined, + albumArtistSort:document.getElementById('ie-albumartistsort').value || undefined, + albumSort: document.getElementById('ie-albumsort').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; + } +} + +export async function deleteSong() { + if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } + const s = S.infoSong; + if (!s?.id) return; + const ok = await showConfirm( + 'Song löschen', + `"${s.title}" wird unwiderruflich von der Festplatte gelöscht. Leere Album- und Künstlerordner werden ebenfalls entfernt.`, + 'Löschen' + ); + if (!ok) return; + const status = document.getElementById('info-edit-status'); + status.textContent = '🗑 Löschen…'; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/delete-song`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); + toast(`🗑 "${s.title}" gelöscht`); + closeInfoModal(); + setTimeout(() => { + loadPlaylists(); + if (document.getElementById('page-playlist-detail')?.classList.contains('active') && S._currentPlaylist) + loadPlaylistDetail(S._currentPlaylist); + }, 3000); + } catch (e) { + status.textContent = '❌ Fehler: ' + e.message; + } +} diff --git a/src/ui/modals.js b/src/ui/modals.js new file mode 100644 index 0000000..54b6405 --- /dev/null +++ b/src/ui/modals.js @@ -0,0 +1,236 @@ +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { api, coverUrl, ndAuth } from '../api.js'; +// Circular — safe (only used inside function bodies) +import { playQueue, dlSongLocal, nativeInsertAt, renderQueue } from '../player/player.js'; +import { navigate, loadPlaylists, loadPlaylistDetail } 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: '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 = `${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(); +}); + +// ─── 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 + ? `` + : '
🎶
'; + 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); } +} + +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); } +} diff --git a/src/ui/notify.js b/src/ui/notify.js new file mode 100644 index 0000000..cc1677d --- /dev/null +++ b/src/ui/notify.js @@ -0,0 +1,146 @@ +import { esc } from '../utils.js'; +// Circular — safe (only used inside function bodies) +import { navigate } from '../views/views.js'; +import { api } from '../api.js'; + +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))); } + +export 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
    ${n.failed.map(f => `
  • ${esc(f)}
  • `).join('')}
` + : ''; + const missingHtml = n.missing?.length + ? `
${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden
    ${n.missing.map(f => `
  • ${esc(f)}
  • `).join('')}
` + : ''; + 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; + 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() || ''; + + 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 || []; + 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 {} + } + import('../utils.js').then(m => m.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 {} + import('../utils.js').then(m => m.toast('Künstler nicht gefunden — Library-Scan nötig?')); + } +} diff --git a/src/ui/settings.js b/src/ui/settings.js new file mode 100644 index 0000000..8d417e6 --- /dev/null +++ b/src/ui/settings.js @@ -0,0 +1,153 @@ +import { S } from '../state.js'; +import { esc, toast } from '../utils.js'; +import { api, fetchRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from '../api.js'; +import { THEMES, applyTheme, getCurrentThemeId } from '../themes.js'; +// Circular — safe (only used inside function bodies) +import { refreshDisplayMode, updateNpColorMode } from '../player/player.js'; +import { startArtistSortFix } from './downloads.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 (legacy stubs — header search is now always visible) ─── +export function toggleMobileSearch() { + document.getElementById('header-search-input')?.focus(); +} +export function closeMobileSearch() { + import('../views/views.js').then(m => m.closeHeaderSearch()); +} + +// ─── 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() { + import('../views/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); + document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji); + document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg); + _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(); +} + +function _renderRomajiOverrides() { + const list = document.getElementById('romaji-overrides-list'); + if (!list) return; + const entries = Object.entries(getRomajiOverrides()); + if (!entries.length) { + list.innerHTML = '
Keine Korrekturen vorhanden
'; + return; + } + list.innerHTML = entries.map(([k, v]) => + `
+ ${esc(k)} + → + ${esc(v)} + +
` + ).join(''); +} + +export async function addRomajiOverrideUI() { + const k = (document.getElementById('ro-kanji')?.value || '').trim(); + const v = (document.getElementById('ro-romaji')?.value || '').trim(); + if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; } + await setRomajiOverride(k, v); + document.getElementById('ro-kanji').value = ''; + document.getElementById('ro-romaji').value = ''; + _renderRomajiOverrides(); + try { await startArtistSortFix(k, v); } catch (e) { toast('⚠ Fix-Artist-Sort: ' + e.message); } +} + +export async function removeRomajiOverrideUI(kanji) { + await removeRomajiOverride(kanji); + _renderRomajiOverrides(); +} +export function openRomajiOverridesModal() { + _renderRomajiOverrides(); + document.getElementById('romaji-overrides-modal').classList.add('open'); +} +export function closeRomajiOverridesModal() { + document.getElementById('romaji-overrides-modal').classList.remove('open'); +} + +export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); } +export async function confirmDisconnect() { + const { showConfirm } = await import('./modals.js'); + 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(); + const credChanged = S.cfg.url !== document.getElementById('s-url').value.trim().replace(/\/$/,'') || !!pass; + if(pass) S.cfg.pass = pass; + if(credChanged) invalidateCoverCache(); + 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'); + const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on'); + S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on'); + S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on'); + updateNpColorMode(); + localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); + closeSettings(); toast('✅ Gespeichert'); + if (romajiChanged) { + fetchRomajiIndex().then(() => refreshDisplayMode()); + } +} +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; } +} diff --git a/src/ui/ui.js b/src/ui/ui.js index fcfabe0..c0a0604 100644 --- a/src/ui/ui.js +++ b/src/ui/ui.js @@ -1,1137 +1,6 @@ -import { S } from '../state.js'; -import { esc, formatDur, toast } from '../utils.js'; -import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert, romajiLookup, invalidateRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } 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, setPlayIcon, nativeInsertAt, refreshDisplayMode, updateNpColorMode } from '../player/player.js'; -import { navigate, loadPlaylists, loadPlaylistDetail } from '../views/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 (legacy stubs — header search is now always visible) ─── -export function toggleMobileSearch() { - document.getElementById('header-search-input')?.focus(); -} -export function closeMobileSearch() { - import('../views/views.js').then(m => m.closeHeaderSearch()); -} - -// ─── 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('../views/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); - document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji); - document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg); - _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(); -} - -function _renderRomajiOverrides() { - const list = document.getElementById('romaji-overrides-list'); - if (!list) return; - const entries = Object.entries(getRomajiOverrides()); - if (!entries.length) { - list.innerHTML = '
Keine Korrekturen vorhanden
'; - return; - } - list.innerHTML = entries.map(([k, v]) => - `
- ${esc(k)} - → - ${esc(v)} - -
` - ).join(''); -} -export async function addRomajiOverrideUI() { - const k = (document.getElementById('ro-kanji')?.value || '').trim(); - const v = (document.getElementById('ro-romaji')?.value || '').trim(); - if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; } - await setRomajiOverride(k, v); - document.getElementById('ro-kanji').value = ''; - document.getElementById('ro-romaji').value = ''; - _renderRomajiOverrides(); - // Batch-fix existing songs in the library - if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return; - try { - const jobId = 'job_' + Date.now(); - addJob(jobId, `Romaji-Fix: ${v}`, k, 'fix-sort'); - const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: k, artistSort: v, jobId }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - pollJob(jobId); - } catch (e) { - toast('⚠ Fix-Artist-Sort: ' + e.message); - } -} -export async function removeRomajiOverrideUI(kanji) { - await removeRomajiOverride(kanji); - _renderRomajiOverrides(); -} -export function openRomajiOverridesModal() { - _renderRomajiOverrides(); - document.getElementById('romaji-overrides-modal').classList.add('open'); -} -export function closeRomajiOverridesModal() { - document.getElementById('romaji-overrides-modal').classList.remove('open'); -} - -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(); - const credChanged = S.cfg.url !== document.getElementById('s-url').value.trim().replace(/\/$/,'') || !!pass; - if(pass) S.cfg.pass = pass; - if(credChanged) invalidateCoverCache(); - 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'); - const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on'); - S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on'); - S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on'); - updateNpColorMode(); - localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); - closeSettings(); toast('✅ Gespeichert'); - if (romajiChanged) { - // Pre-load the index so display is instant, then refresh all live UI - fetchRomajiIndex().then(() => refreshDisplayMode()); - } -} -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: '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: '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 ─── -// Normalize various artist separators (•, ;, ,) for display in the edit form. -// Track artist → "Serph; Zun" | Album artist → "Serph, Zun" -function _fmtArtists(str, sep) { - if (!str) return ''; - return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep); -} - -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-Künstler', s.albumArtist], - ['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) { - // getSong often omits albumArtist — fill it from getAlbum which always has album.artist - if (!full.albumArtist && full.albumId) { - try { - const ar = await api('getAlbum', `&id=${full.albumId}`); - if (ar.album?.artist) full.albumArtist = ar.album.artist; - } catch {} - } - S.infoSong = full; - _renderInfoModal(full); - // If edit mode was already opened before getSong returned, refresh the fields - // so the form shows the authoritative data (albumArtist, etc.) from Navidrome - if (document.getElementById('info-edit-mode').style.display !== 'none') { - document.getElementById('ie-title').value = full.title || ''; - document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || ''; - document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || ''; - document.getElementById('ie-album').value = full.album || ''; - document.getElementById('ie-genre').value = full.genre || ''; - document.getElementById('ie-year').value = full.year || ''; - document.getElementById('ie-track').value = full.track || ''; - document.getElementById('ie-disc').value = full.discNumber || ''; - document.getElementById('ie-totaltracks').value = full.albumSongCount || ''; - // sort tags are populated by the async chain in toggleInfoEdit — no need to reset here - } - } - } 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 = _fmtArtists(s.artist, '; ') || ''; - document.getElementById('ie-albumartist').value = _fmtArtists(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 || ''; - - // Clear sort fields immediately so stale values from a previously opened song don't show - document.getElementById('ie-titlesort').value = ''; - document.getElementById('ie-artistsort').value = ''; - document.getElementById('ie-albumartistsort').value = ''; - document.getElementById('ie-albumsort').value = ''; - - // Sort tags: fetch romaji index on demand (cached if already loaded), then overwrite with - // actual file tags from backend. This works regardless of the showRomaji toggle. - const _sId = s.id; - fetchRomajiIndex().catch(() => null).then(() => { - const rm = romajiLookup(_sId); - if (rm) { - const _jp = /[ぁ-んァ-ン一-龯]/; - if (rm.titleRomaji && !_jp.test(rm.titleRomaji)) document.getElementById('ie-titlesort').value = rm.titleRomaji; - if (rm.artistRomaji && !_jp.test(rm.artistRomaji)) { - document.getElementById('ie-artistsort').value = rm.artistRomaji; - document.getElementById('ie-albumartistsort').value = rm.artistRomaji; - } - if (rm.albumRomaji && !_jp.test(rm.albumRomaji)) document.getElementById('ie-albumsort').value = rm.albumRomaji; - } - if (!S.cfg.backendUrl || !_sId) return null; - const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId }); - return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null); - }).then(t => { - if (t) { - const _jp = /[ぁ-んァ-ン一-龯]/; - // Only use file sort tags if they are non-Japanese (romaji/ASCII) — Japanese sort tags - // in the file are incorrect and should be ignored in favour of the romaji index value. - if (t.titleSort && !_jp.test(t.titleSort)) document.getElementById('ie-titlesort').value = t.titleSort; - if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort; - if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort; - if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort; - } - // Manual overrides — highest priority, always applied last. - const ov = getRomajiOverrides(); - if (s.artist && ov[s.artist]) { document.getElementById('ie-artistsort').value = ov[s.artist]; document.getElementById('ie-albumartistsort').value = ov[s.artist]; } - if (s.albumArtist && ov[s.albumArtist]) document.getElementById('ie-albumartistsort').value = ov[s.albumArtist]; - if (s.title && ov[s.title]) document.getElementById('ie-titlesort').value = ov[s.title]; - if (s.album && ov[s.album]) document.getElementById('ie-albumsort').value = ov[s.album]; - }).catch(() => {}); - - // 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 autofillItunes() { - 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 = '🔍 Apple Music…'; - try { - const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); - const d = await r.json(); - if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; } - 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; - if (d.coverUrl) { - S._infoArtData = { url: d.coverUrl }; - document.getElementById('info-edit-art-preview').innerHTML = - ``; - status.textContent = '✅ Metadaten + Cover von Apple Music'; - } else { - status.textContent = '✅ Metadaten von Apple Music (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, - titleSort: document.getElementById('ie-titlesort').value || undefined, - artistSort: document.getElementById('ie-artistsort').value || undefined, - albumArtistSort:document.getElementById('ie-albumartistsort').value || undefined, - albumSort: document.getElementById('ie-albumsort').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; - } -} - -export async function deleteSong() { - if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } - const s = S.infoSong; - if (!s?.id) return; - const ok = await showConfirm( - 'Song löschen', - `"${s.title}" wird unwiderruflich von der Festplatte gelöscht. Leere Album- und Künstlerordner werden ebenfalls entfernt.`, - 'Löschen' - ); - if (!ok) return; - const status = document.getElementById('info-edit-status'); - status.textContent = '🗑 Löschen…'; - try { - const r = await fetch(`${S.cfg.backendUrl}/api/delete-song`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id }), - }); - const d = await r.json(); - if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); - toast(`🗑 "${s.title}" gelöscht`); - closeInfoModal(); - // Refresh playlist data after Navidrome's background scan has a chance to finish. - // Always reload playlists (even if not active) so counts are fresh when navigating back. - setTimeout(() => { - loadPlaylists(); - if (document.getElementById('page-playlist-detail')?.classList.contains('active') && S._currentPlaylist) - loadPlaylistDetail(S._currentPlaylist); - }, 3000); - } 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 ov = getRomajiOverrides(); - const _artist = S.downloadTarget.artistName || 'Unknown Artist'; - const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown'; - const _album = S.downloadTarget.collectionName || ''; - const payload = { - type: S.dlType, - artist: _artist, - title: _title, - album: _album, - 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, - ...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }), - ...(ov[_title] && { titleSort: ov[_title] }), - ...(ov[_album] && { albumSort: ov[_album] }), - }; - 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); - const ov = getRomajiOverrides(); - const enrichedTracks = (tracks || []).map(t => { - const a = t.artistName || ''; - const n = t.trackName || ''; - const al = t.collectionName || ''; - return { - ...t, - ...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }), - ...(ov[n] && { titleSort: ov[n] }), - ...(ov[al] && { albumSort: ov[al] }), - }; - }); - 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: enrichedTracks }), - }); - 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, ''); - const JP_RE = /[぀-ヿ一-鿿]/; - // Pre-fetch romaji index once if any track has Japanese chars — Navidrome search3 - // doesn't handle Japanese text, so we need the index as a fallback. - const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName)); - const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null; - // Pre-convert all Japanese titles to romaji in one batch — needed for songs that were - // already downloaded with romaji tags (they're no longer in the romaji index). - const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : ''); - const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles; - const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); - for (const [idx, t] of tracks.entries()) { - 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); - let match = songs.find(s => titleMatch(s) && artistMatch(s)) - || songs.find(s => titleMatch(s)); - // Fallback 1: search romaji index by original Japanese title - // (for old songs that still have Japanese in Navidrome) - if (!match && romajiIdx && JP_RE.test(t.trackName)) { - const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName)); - if (jpEntry) match = { id: jpEntry.id }; - } - // Fallback 2: search Navidrome with romaji-converted title - // (for songs downloaded after romaji changes — stored with romaji tags) - if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) { - const rTitle = romajiTitles[idx]; - try { - const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0'); - const s3 = r3.searchResult3?.song || []; - match = s3.find(s => normR(s.title) === normR(rTitle)) || null; - } catch { /* ignore */ } - } - 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
    ${n.failed.map(f => `
  • ${esc(f)}
  • `).join('')}
` - : ''; - const missingHtml = n.missing?.length - ? `
${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden
    ${n.missing.map(f => `
  • ${esc(f)}
  • `).join('')}
` - : ''; - 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!`); - if (!d.skipped) invalidateRomajiIndex(); - } - } - } 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); } -} - +// Barrel — re-exports everything so external imports continue to use a single entry point. +export * from './settings.js'; +export * from './modals.js'; +export * from './metadata.js'; +export * from './notify.js'; +export * from './downloads.js';