Ein Song oder Album laesst sich per https://<host>/?song=<id> an jemanden auf derselben Instanz schicken. Der Link traegt nur die Navidrome-Id, keine Zugangsdaten — der Empfaenger sieht den Inhalt mit seinem eigenen Konto. Web: Teilen ueber navigator.share() mit Rueckfall auf die Zwischenablage. Beim Start wird ?song= / ?album= aufgeloest (nach der Anmeldung, vorher fehlen die Zugangsdaten), das Album geoeffnet, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. Das Manifest bekommt launch_handler=navigate-existing, damit der Link in einem bereits offenen PWA-Fenster landet statt in einem neuen. Android: Teilen ueber ACTION_SEND, Empfang ueber einen App-Link-Intent-Filter. Der Host ist eine Build-Eigenschaft (-PnovasonicHost=…, verifiziert am zusammengefuehrten Manifest), die Web-Adresse fuer ausgehende Links eine neue Einstellung. Weil der NavController in der Compose-Hierarchie lebt und die Activity nicht selbst navigieren kann, reicht DeepLinkState den Link durch; onNewIntent ist mitbedacht, sonst passierte beim zweiten Link nichts. assetlinks.json liegt als Vorlage in public/ und wird vom Frontend-Image ausgeliefert (im Build verifiziert); ohne den Release-Fingerabdruck oeffnet der Link die Web-App im Browser statt die App. Anleitung: docs/share-links.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
13 KiB
JavaScript
243 lines
13 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast } from '../utils.js';
|
||
import { api, coverUrl, ndAuth, invalidateApiCache } 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: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
|
||
{ 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';
|
||
const cv = pl.coverArt ? coverUrl(pl.coverArt, 96) : null;
|
||
const inner = cv
|
||
? `<img src="${cv}" alt="" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">`
|
||
: '<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>';
|
||
row.innerHTML = `<span style="width:34px;height:34px;border-radius:6px;flex-shrink:0;background:var(--surface2);display:flex;align-items:center;justify-content:center;overflow:hidden;color:var(--text3)">${inner}</span>
|
||
<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`);
|
||
invalidateApiCache('getPlaylists'); // drop the 3-min cache so the list reloads without the deleted playlist
|
||
navigate('playlists');
|
||
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
|
||
}
|