refactor: split ui/ui.js into 5 focused modules
settings.js — settings modal, romaji overrides, sidebar, theme, nav mode modals.js — context menu, playlist-add modal, confirm dialog, edit-playlist modal metadata.js — song info/edit modal, MusicBrainz/iTunes autofill, saveMetadata, deleteSong notify.js — notification panel, bell badge, _addNotif, _notifNavigate downloads.js — download modal, Spotify download, jobs panel, startArtistSortFix ui.js — barrel re-export; all external imports unchanged player.js left unsplit: shared private state (_nativePlaying, _playGen, _queueGen, etc.) spans every section — splitting would require exporting internal state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a2e5ea495
commit
0663b1c923
6 files changed
+1125
-1137
No files matched your search
@@ -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 ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
|
||||
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
|
||||
document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen';
|
||||
document.getElementById('download-modal').classList.add('open');
|
||||
}
|
||||
export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); }
|
||||
export function selectDlType(el, type) {
|
||||
S.dlType = type;
|
||||
document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
}
|
||||
export async function executeDownload() {
|
||||
if(!S.downloadTarget || !S.cfg.backendUrl) return;
|
||||
closeDlModal();
|
||||
const jobId = 'job_' + Date.now();
|
||||
const 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 = `<div class="job-header">
|
||||
<div class="job-title">${esc(j.title)}</div>
|
||||
<div class="job-status">${esc(j.artist)}</div>
|
||||
</div>
|
||||
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
|
||||
<div class="job-sub">${statusText}</div>`;
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div>';
|
||||
document.getElementById('info-top-title').textContent = s.title || '—';
|
||||
document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : '');
|
||||
const fields = [
|
||||
['Titel', s.title],
|
||||
['Künstler', s.artist],
|
||||
['Album-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]) =>
|
||||
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
export async function showSongInfo(s) {
|
||||
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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>';
|
||||
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 =
|
||||
`<img src="${dataUrl}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
};
|
||||
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 =
|
||||
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
|
||||
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 =
|
||||
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
|
||||
el.onclick = () => { hideCtxMenu(); item.fn(); };
|
||||
menu.appendChild(el);
|
||||
});
|
||||
const vw=window.innerWidth, vh=window.innerHeight;
|
||||
let x=e.clientX, y=e.clientY;
|
||||
menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block';
|
||||
const w=menu.offsetWidth, h=menu.offsetHeight;
|
||||
if(x+w>vw-8) x=vw-w-8;
|
||||
if(y+h>vh-8) y=vh-h-8;
|
||||
menu.style.left=x+'px'; menu.style.top=y+'px';
|
||||
}
|
||||
export function hideCtxMenu() {
|
||||
const m = document.getElementById('ctx-menu');
|
||||
if (!m) return;
|
||||
m.style.display='none'; m.innerHTML='';
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const m = document.getElementById('ctx-menu');
|
||||
if (m && !m.contains(e.target)) hideCtxMenu();
|
||||
});
|
||||
document.addEventListener('scroll', () => {
|
||||
const m = document.getElementById('ctx-menu');
|
||||
if (m && m.style.display !== 'none') hideCtxMenu();
|
||||
}, { passive: true, capture: true });
|
||||
document.addEventListener('touchmove', () => {
|
||||
const m = document.getElementById('ctx-menu');
|
||||
if (m && m.style.display !== 'none') hideCtxMenu();
|
||||
}, { passive: true, capture: true });
|
||||
document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); });
|
||||
|
||||
// ─── PLAYLIST MODAL (Add to / Create) ───
|
||||
export async function openPlModal(songs) {
|
||||
S.plModalSongs = songs;
|
||||
document.getElementById('pl-modal-title').textContent =
|
||||
songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
|
||||
document.getElementById('pl-new-name').value = '';
|
||||
const list = document.getElementById('pl-list');
|
||||
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
|
||||
document.getElementById('pl-modal').classList.add('open');
|
||||
try {
|
||||
const r = await api('getPlaylists');
|
||||
S.cachedPlaylists = r.playlists?.playlist || [];
|
||||
list.innerHTML = '';
|
||||
if(!S.cachedPlaylists.length) {
|
||||
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
|
||||
return;
|
||||
}
|
||||
S.cachedPlaylists.forEach(pl => {
|
||||
const row = document.createElement('div'); row.className = 'pl-row';
|
||||
row.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>
|
||||
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
|
||||
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
|
||||
list.appendChild(row);
|
||||
});
|
||||
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
|
||||
}
|
||||
export function closePlModal() {
|
||||
document.getElementById('pl-modal').classList.remove('open');
|
||||
S.plModalSongs = [];
|
||||
}
|
||||
export async function addSongsToPlaylist(plId, plName) {
|
||||
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 = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
|
||||
document.getElementById('pl-modal').classList.add('open');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const m = document.getElementById('pl-modal');
|
||||
if(m.classList.contains('open') && e.target===m) closePlModal();
|
||||
});
|
||||
|
||||
// ─── CONFIRM DIALOG ───
|
||||
let _confirmResolve = null;
|
||||
export function _confirmOk() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(true); _confirmResolve = null; }
|
||||
export function _confirmCancel() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(false); _confirmResolve = null; }
|
||||
|
||||
export function showConfirm(title, msg, okLabel = 'Bestätigen') {
|
||||
document.getElementById('confirm-title').textContent = title;
|
||||
document.getElementById('confirm-msg').textContent = msg;
|
||||
document.getElementById('confirm-ok-btn').textContent = okLabel;
|
||||
document.getElementById('confirm-overlay').classList.add('open');
|
||||
return new Promise(resolve => { _confirmResolve = resolve; });
|
||||
}
|
||||
|
||||
// ─── EDIT PLAYLIST MODAL ───
|
||||
export function openEditPlModal(pl) {
|
||||
const overlay = document.getElementById('edit-pl-overlay');
|
||||
document.getElementById('edit-pl-name').value = pl.name || '';
|
||||
document.getElementById('edit-pl-desc').value = pl.comment || '';
|
||||
const tog = document.getElementById('edit-pl-public-toggle');
|
||||
tog.classList.toggle('on', !!pl.public);
|
||||
const cv = coverUrl(pl.coverArt, 300);
|
||||
document.getElementById('edit-pl-cover').innerHTML = cv
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); }
|
||||
export async function saveEditPlaylist() {
|
||||
const pl = S._currentPlaylist; if(!pl) return;
|
||||
const name = document.getElementById('edit-pl-name').value.trim();
|
||||
if(!name) { toast('Name darf nicht leer sein'); return; }
|
||||
const comment = document.getElementById('edit-pl-desc').value.trim();
|
||||
const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on');
|
||||
const btn = document.querySelector('.edit-pl-btn-save');
|
||||
btn.disabled=true; btn.textContent='Speichern…';
|
||||
try {
|
||||
await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
|
||||
S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub};
|
||||
document.getElementById('playlist-detail-name').textContent = name;
|
||||
const descEl = document.getElementById('playlist-detail-desc');
|
||||
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
|
||||
closeEditPlModal();
|
||||
toast('Playlist aktualisiert');
|
||||
} catch(e) { toast('Fehler: '+e.message); }
|
||||
finally { btn.disabled=false; btn.textContent='Speichern'; }
|
||||
}
|
||||
export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); }
|
||||
export async function editPlCoverSelected(input) {
|
||||
const file = input.files?.[0]; if(!file || !S._currentPlaylist) return;
|
||||
input.value = '';
|
||||
const preview = URL.createObjectURL(file);
|
||||
document.getElementById('edit-pl-cover').innerHTML = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
try {
|
||||
const token = await ndAuth();
|
||||
const form = new FormData(); form.append('image', file, file.name);
|
||||
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
|
||||
method: 'POST',
|
||||
headers: {'X-Nd-Authorization': 'Bearer '+token},
|
||||
body: form,
|
||||
});
|
||||
if(!res.ok) throw new Error(res.status);
|
||||
toast('Cover aktualisiert');
|
||||
setTimeout(() => {
|
||||
const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now();
|
||||
const detailImg = document.getElementById('playlist-cover');
|
||||
if(detailImg) detailImg.innerHTML = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
|
||||
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
}, 800);
|
||||
} catch(e) {
|
||||
S._ndToken = null;
|
||||
toast('Cover-Upload fehlgeschlagen: '+e.message);
|
||||
const cv = coverUrl(S._currentPlaylist.coverArt, 300);
|
||||
document.getElementById('edit-pl-cover').innerHTML = cv
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
}
|
||||
}
|
||||
export async function editPlRemoveCover() {
|
||||
if(!S._currentPlaylist) return;
|
||||
try {
|
||||
const token = await ndAuth();
|
||||
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
|
||||
method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token},
|
||||
});
|
||||
if(!res.ok) throw new Error(res.status);
|
||||
toast('Cover entfernt');
|
||||
S._currentPlaylist.coverArt = null;
|
||||
document.getElementById('edit-pl-cover').innerHTML = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
|
||||
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
@@ -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 = '<div class="notif-empty">Keine Benachrichtigungen</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notifs.map(n => {
|
||||
const d = new Date(n.timestamp);
|
||||
const time = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
|
||||
const icon = n.status === 'done' ? '✅' : '❌';
|
||||
const failedHtml = n.failed?.length
|
||||
? `<details class="notif-failed"><summary>${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen</summary><ul>${n.failed.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
|
||||
: '';
|
||||
const missingHtml = n.missing?.length
|
||||
? `<details class="notif-failed"><summary>${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden</summary><ul>${n.missing.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
|
||||
: '';
|
||||
const hasNav = n.status === 'done' && ((n.navType === 'playlist' && n.navId) || n.navArtist || n.navAlbum);
|
||||
const navLabel = n.navType === 'artist' ? 'Zum Künstler →'
|
||||
: (n.navType === 'track' || n.navType === 'album') ? 'Zum Album →'
|
||||
: 'Öffnen →';
|
||||
const navBtn = hasNav
|
||||
? `<button class="notif-nav-btn" onclick="_notifNavigate('${esc(n.id)}')">${navLabel}</button>`
|
||||
: '';
|
||||
return `<div class="notif-card${n.read ? '' : ' unread'}">
|
||||
<div class="notif-card-top">
|
||||
<span class="notif-icon">${icon}</span>
|
||||
<div class="notif-info">
|
||||
<div class="notif-title">${esc(n.title)}</div>
|
||||
<div class="notif-sub">${n.artist ? esc(n.artist) + ' · ' : ''}${time}</div>
|
||||
</div>
|
||||
<button class="notif-x" onclick="deleteNotif('${esc(n.id)}')">✕</button>
|
||||
</div>
|
||||
${n.message ? `<div class="notif-msg">${esc(n.message)}</div>` : ''}
|
||||
${failedHtml}
|
||||
${missingHtml}
|
||||
${navBtn}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export async function _notifNavigate(notifId) {
|
||||
const notifs = _loadNotifs();
|
||||
const n = notifs.find(x => x.id === notifId);
|
||||
if (!n) return;
|
||||
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?'));
|
||||
}
|
||||
}
|
||||
@@ -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 => `
|
||||
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
|
||||
<div class="theme-swatch-dot"></div>
|
||||
<span>${esc(t.name)}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
export function _pickTheme(id) {
|
||||
applyTheme(id);
|
||||
_renderThemePicker();
|
||||
}
|
||||
|
||||
function _renderRomajiOverrides() {
|
||||
const list = document.getElementById('romaji-overrides-list');
|
||||
if (!list) return;
|
||||
const entries = Object.entries(getRomajiOverrides());
|
||||
if (!entries.length) {
|
||||
list.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:4px 0">Keine Korrekturen vorhanden</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries.map(([k, v]) =>
|
||||
`<div style="display:flex;align-items:center;gap:6px;padding:7px 10px;background:var(--bg2);border-radius:8px">
|
||||
<span style="flex:1;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(k)}</span>
|
||||
<span style="color:var(--text2);flex-shrink:0">→</span>
|
||||
<span style="flex:1.2;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(v)}</span>
|
||||
<button onclick="removeRomajiOverrideUI('${esc(k)}')" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:18px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
|
||||
</div>`
|
||||
).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; }
|
||||
}
|
||||
+6
-1137
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user