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 = `
+
+ ${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 => `
+ `).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 => `
- `).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 = `
-
- ${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';