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:
HoneyandClaude Sonnet 4.6 committed 2026-06-07 14:00:38 +02:00
1 parent 2a2e5ea495
commit 0663b1c923
6 files changed
+1125 -1137

No files matched your search

+236
View File
@@ -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); }
}