/*
* Quick-switch modal β active Metadata / Server / Download source selection.
* Opens from the sidebar Service Status panel; styled after the Manage Workers
* hub (topbar + rail + panel, brand-logo cards).
*
* Admin writes the GLOBAL active source/server/download (same as Settings).
* Non-admins see it read-only for now (per-profile override is a later layer):
* the backend reports `editable`, and the UI disables changes when false.
*
* Backend: GET /api/profiles/me/active-sources, POST /api/profiles/active-sources.
*/
const _SS_TABS = [
{ id: 'metadata', name: 'Metadata', emoji: 'πΌ' },
{ id: 'server', name: 'Server', emoji: 'π₯οΈ' },
{ id: 'download', name: 'Download', emoji: 'β¬οΈ' },
];
// Brand logos. Metadata pulls from SOURCE_LABELS (shared-helpers.js) when
// available; server + download have their own small maps.
const _SS_SERVER_INFO = {
// `dark`: the logo is a white/light wordmark, so it needs a dark disc to be
// visible (it'd vanish on the default white disc).
plex: { name: 'Plex', logo: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg', dark: true },
jellyfin: { name: 'Jellyfin', logo: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png' },
navidrome: { name: 'Navidrome', logo: 'https://tweakers.net/ext/i/2007323764.png' },
soulsync: { name: 'SoulSync', logo: '/static/trans2.png', dark: true },
};
const _SS_META_FALLBACK = {
spotify_free: { text: 'Spotify (no auth)', icon: 'π', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' },
};
// Brand colors drive each card's logo ring + active glow (the Manage-Workers feel).
const _SS_BRAND = {
spotify: '#1db954', spotify_free: '#1db954', itunes: '#fc5c7d', deezer: '#a238ff',
discogs: '#ff5500', musicbrainz: '#ba478f', amazon: '#ff9900',
plex: '#e5a00d', jellyfin: '#aa5cc3', navidrome: '#3b6cf6', soulsync: '#7c5cff',
soulseek: '#22a7f0', youtube: '#ff0000', tidal: '#00cfe8', qobuz: '#0a6e9e',
hifi: '#16c79a', torrent: '#8a2be2', usenet: '#e67e22',
};
function _ssBrand(id) { return _SS_BRAND[id] || 'var(--accent-light-rgb-hex, #7c5cff)'; }
let _ssState = { tab: 'metadata', data: null };
function _ssEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function _ssMetaInfo(id) {
if (typeof SOURCE_LABELS !== 'undefined' && SOURCE_LABELS[id]) return SOURCE_LABELS[id];
if (_SS_META_FALLBACK[id]) return _SS_META_FALLBACK[id];
return { text: id, icon: 'π΅' };
}
function _ssDownloadInfo(id) {
if (typeof HYBRID_SOURCES !== 'undefined') {
const h = HYBRID_SOURCES.find(s => s.id === id);
if (h) return { name: h.name, logo: h.icon, emoji: h.emoji };
}
return { name: id, emoji: 'β¬οΈ' };
}
function openServiceSwitchModal(tab) {
// Admin-only: active metadata source / media server / download source are
// app-wide infrastructure. Non-admins manage their own playlist accounts
// elsewhere (per-profile), not here.
try {
const ctx = (typeof getCurrentProfileContext === 'function') ? getCurrentProfileContext() : null;
if (ctx && !ctx.isAdmin) {
if (typeof showToast === 'function') showToast('Only the admin can change the active sources', 'info');
return;
}
} catch (_e) { /* if context unknown, fall through (defaults to admin) */ }
_ssState.tab = _SS_TABS.some(t => t.id === tab) ? tab : 'metadata';
let overlay = document.getElementById('service-switch-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'service-switch-overlay';
overlay.className = 'modal-overlay ss-overlay hidden';
overlay.onclick = (e) => { if (e.target === overlay) closeServiceSwitchModal(); };
overlay.innerHTML = `
Active Sources
What this profile uses for metadata, library, and downloads
`;
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden', 'ss-closing');
const modal = overlay.querySelector('.ss-modal');
if (modal) { modal.classList.remove('ss-in'); void modal.offsetWidth; modal.classList.add('ss-in'); }
document.addEventListener('keydown', _ssOnKeydown);
_ssLoad();
}
function closeServiceSwitchModal() {
const o = document.getElementById('service-switch-overlay');
if (o) o.classList.add('hidden');
document.removeEventListener('keydown', _ssOnKeydown);
}
function _ssOnKeydown(e) { if (e.key === 'Escape') closeServiceSwitchModal(); }
async function _ssLoad() {
_ssRenderRail();
const panel = document.getElementById('ss-panel');
if (panel) panel.innerHTML = 'Loadingβ¦
';
try {
const res = await fetch('/api/profiles/me/active-sources');
_ssState.data = await res.json();
} catch (e) {
_ssState.data = null;
}
_ssRenderRail(); // re-render now that we know each tab's active choice
_ssRenderPanel();
}
function _ssRailCurrent(tabId) {
// The active choice for a tab β {logo/emoji, label, brand} for the rail chip.
const d = _ssState.data;
if (!d || !d.success) return null;
if (tabId === 'metadata') {
const id = d.metadata.active; const info = _ssMetaInfo(id);
return { logo: info.logo, emoji: info.icon, label: info.text || id, brand: _ssBrand(id) };
}
if (tabId === 'server') {
const id = d.server.active; const info = _SS_SERVER_INFO[id] || { name: id };
return { logo: info.logo, emoji: 'π₯οΈ', label: info.name, brand: _ssBrand(id), dark: info.dark };
}
const id = d.download.mode;
if (id === 'hybrid') return { emoji: 'π', label: 'Hybrid', brand: 'var(--accent-light-rgb-hex,#7c5cff)' };
const info = _ssDownloadInfo(id);
return { logo: info.logo, emoji: info.emoji, label: info.name, brand: _ssBrand(id) };
}
function _ssRenderRail() {
const rail = document.getElementById('ss-rail');
if (!rail) return;
rail.innerHTML = _SS_TABS.map(t => {
const cur = _ssRailCurrent(t.id);
const media = cur
? (cur.logo
? `
`
: `${cur.emoji}`)
: `${t.emoji}`;
return `
`;
}).join('');
}
function switchServiceSwitchTab(tab) {
_ssState.tab = tab;
_ssRenderRail();
_ssRenderPanel();
}
function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand, dark }) {
const dim = available === false ? ' ss-card--locked' : '';
const act = active ? ' active' : '';
const media = logo
? `
`
: `${emoji || 'π΅'}`;
return `
`;
}
const _SS_TAB_BLURB = {
metadata: 'Where artist, album & track details come from.',
server: 'The library backend SoulSync reads and writes.',
download: 'Where SoulSync grabs tracks you don\'t have yet.',
};
function _ssHero(kind) {
const cur = _ssRailCurrent(kind);
if (!cur) return '';
const media = cur.logo
? `
`
: `${cur.emoji}`;
const eyebrow = kind === 'metadata' ? 'Active metadata source'
: kind === 'server' ? 'Active media server' : 'Active download source';
return `
${media}
${eyebrow}
${_ssEsc(cur.label)}
${_SS_TAB_BLURB[kind] || ''}
Active
`;
}
function _ssRenderPanel() {
const panel = document.getElementById('ss-panel');
const d = _ssState.data;
if (!panel) return;
if (!d || !d.success) { panel.innerHTML = 'Could not load active sources.
'; return; }
const editable = !!d.editable;
panel.style.setProperty('--ss-brand', (_ssRailCurrent(_ssState.tab) || {}).brand || '#7c5cff');
const sub = document.getElementById('ss-topbar-sub');
if (sub) sub.textContent = editable
? 'What this profile uses for metadata, library, and downloads'
: 'Set by the admin β view only for now';
if (_ssState.tab === 'metadata') {
const cards = d.metadata.options.map(o => {
const info = _ssMetaInfo(o.id);
return _ssCard({
logo: info.logo, emoji: info.icon, label: info.text || o.id, brand: _ssBrand(o.id),
active: d.metadata.active === o.id, available: o.available,
onclick: (editable && o.available) ? `setActiveSource('metadata','${o.id}')` : null,
});
}).join('');
// Surface the EFFECTIVE source when it differs from the configured one
// (e.g. configured Spotify but not authenticated β running on a fallback).
const eff = d.metadata.effective;
const note = (eff && eff !== d.metadata.active)
? `Configured source isn't connected β actually using ${_ssEsc((_ssMetaInfo(eff).text) || eff)} right now.
`
: '';
panel.innerHTML = `${_ssHero('metadata')}Choose source
${note}${cards}
`;
} else if (_ssState.tab === 'server') {
const cards = d.server.options.map(o => {
const info = _SS_SERVER_INFO[o.id] || { name: o.id };
return _ssCard({
logo: info.logo, emoji: 'π₯οΈ', label: info.name, brand: _ssBrand(o.id), dark: info.dark,
active: d.server.active === o.id, available: o.available,
onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null,
});
}).join('');
panel.innerHTML = `${_ssHero('server')}Choose server
${cards}
`;
} else {
_ssRenderDownloadPanel(panel, d, editable);
}
}
function _ssRenderDownloadPanel(panel, d, editable) {
const isHybrid = d.download.mode === 'hybrid';
const toggle = `
`;
let body;
if (isHybrid) {
const order = (d.download.hybrid_order && d.download.hybrid_order.length)
? d.download.hybrid_order
: d.download.options.map(o => o.id);
body = `Drag to set priority β SoulSync tries each in order.
` +
order.map((id, i) => {
const info = _ssDownloadInfo(id);
return `
${i + 1}
${info.logo ? `

` : `
${info.emoji}`}
${_ssEsc(info.name)}
`;
}).join('') + `
`;
} else {
const cards = d.download.options.map(o => {
const info = _ssDownloadInfo(o.id);
return _ssCard({
logo: info.logo, emoji: info.emoji, label: info.name, brand: _ssBrand(o.id),
active: d.download.mode === o.id, available: true,
onclick: editable ? `setActiveSource('download','${o.id}')` : null,
});
}).join('');
body = `${cards}
`;
}
panel.innerHTML = `${_ssHero('download')}Choose source
${toggle}${body}`;
if (isHybrid && editable) _ssWireHybridDrag();
}
function _ssWireHybridDrag() {
const list = document.getElementById('ss-hybrid-list');
if (!list) return;
list.querySelectorAll('.ss-hybrid-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.dataset.src);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', (e) => { e.preventDefault(); });
item.addEventListener('drop', (e) => {
e.preventDefault();
const dragged = e.dataTransfer.getData('text/plain');
if (dragged && dragged !== item.dataset.src) _ssReorderHybrid(dragged, item.dataset.src);
});
});
}
function _ssReorderHybrid(draggedId, targetId) {
const order = (_ssState.data.download.hybrid_order && _ssState.data.download.hybrid_order.length)
? _ssState.data.download.hybrid_order.slice()
: _ssState.data.download.options.map(o => o.id);
const from = order.indexOf(draggedId);
if (from < 0) return;
order.splice(from, 1);
const to = order.indexOf(targetId);
order.splice(to < 0 ? order.length : to, 0, draggedId);
_ssSave({ hybrid_order: order });
}
async function setActiveSource(kind, id) {
const key = kind === 'metadata' ? 'metadata_source' : kind === 'server' ? 'media_server' : 'download_mode';
await _ssSave({ [key]: id });
}
async function setDownloadMode(which) {
if (which === 'hybrid') {
await _ssSave({ download_mode: 'hybrid' });
} else {
// Switch to a single source β keep the current single choice if it was
// already single, else default to the first option.
const d = _ssState.data;
const cur = d.download.mode;
const single = (cur && cur !== 'hybrid') ? cur : (d.download.options[0] && d.download.options[0].id) || 'soulseek';
await _ssSave({ download_mode: single });
}
}
async function _ssSave(patch) {
try {
const res = await fetch('/api/profiles/active-sources', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!data.success) {
if (typeof showToast === 'function') showToast(data.error || 'Change failed', 'error');
return;
}
if (typeof showToast === 'function') showToast('Updated', 'success');
await _ssLoad(); // re-read + re-render with the new active state
if (typeof fetchAndUpdateServiceStatus === 'function') fetchAndUpdateServiceStatus();
} catch (e) {
if (typeof showToast === 'function') showToast('Change failed', 'error');
}
}