// INITIALIZATION
// ===============================
let navigationEpoch = 0;
let _optimisticNavPageId = null;
// Schedule heavy per-page init during browser idle time so navigation paints and
// the page becomes scrollable first. timeout caps the delay so content still loads
// promptly. Falls back to a macrotask in browsers without requestIdleCallback.
function _scheduleHeavyInit(fn) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(fn, { timeout: 200 });
} else {
setTimeout(fn, 0);
}
}
function notifyPageWillChange(nextPageId) {
const fromPageId = typeof currentPage === 'string' ? currentPage : null;
if (fromPageId === nextPageId) return;
window.dispatchEvent(
new CustomEvent(PAGE_WILL_CHANGE_EVENT, {
detail: {
fromPageId,
toPageId: nextPageId,
},
}),
);
}
// ---- Accent Color System ----
function getAccentFallbackColors() {
let accent = localStorage.getItem('soulsync-accent') || '#1db954';
if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954';
// Compute a lighter variant for the second color
const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16);
const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)]
.map(v => v.toString(16).padStart(2, '0')).join('');
return [accent, lighter];
}
function applyAccentColor(hex) {
// Validate hex format — reject corrupt values
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) {
hex = '#1db954'; // fallback to default
}
// Convert hex to RGB
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
// Convert RGB to HSL
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
}
// Compute light variant: +16% lightness
const lightL = Math.min(l + 0.16, 0.95);
// Compute neon variant: high lightness + boosted saturation
const neonL = Math.min(l + 0.30, 0.95);
const neonS = Math.min(s + 0.1, 1.0);
function hslToRgb(h, s, l) {
if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; }
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
}
const light = hslToRgb(h, s, lightL);
const neon = hslToRgb(h, neonS, neonL);
const root = document.documentElement.style;
root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`);
root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`);
// Store for instant restore on next page load
localStorage.setItem('soulsync-accent', hex);
// Update preview swatch if it exists
const swatch = document.getElementById('accent-preview-swatch');
if (swatch) swatch.style.background = hex;
}
function applyParticlesSetting(enabled) {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = enabled ? '' : 'none';
if (window.pageParticles) {
if (enabled) {
const activePage = document.querySelector('.page.active');
if (activePage) {
window.pageParticles.setPage(activePage.id.replace('-page', ''));
}
} else {
window.pageParticles.stop();
}
}
window._particlesEnabled = enabled;
localStorage.setItem('soulsync-particles', String(enabled));
}
function applyWorkerOrbsSetting(enabled) {
window._workerOrbsEnabled = enabled;
localStorage.setItem('soulsync-worker-orbs', String(enabled));
if (window.workerOrbs) {
if (enabled) {
const activePage = document.querySelector('.page.active');
if (activePage && activePage.id === 'dashboard-page') {
window.workerOrbs.setPage('dashboard');
}
} else {
window.workerOrbs.setPage('_disabled');
}
}
}
function initAccentColorListeners() {
const presetSelect = document.getElementById('accent-preset');
const customGroup = document.getElementById('custom-color-group');
const customPicker = document.getElementById('accent-custom-color');
if (!presetSelect) return;
presetSelect.addEventListener('change', () => {
const val = presetSelect.value;
if (val === 'custom') {
if (customGroup) customGroup.style.display = '';
if (customPicker) applyAccentColor(customPicker.value);
} else {
if (customGroup) customGroup.style.display = 'none';
applyAccentColor(val);
}
});
if (customPicker) {
customPicker.addEventListener('input', () => {
applyAccentColor(customPicker.value);
});
}
// Particles toggle — apply immediately on change
const particlesCheckbox = document.getElementById('particles-enabled');
if (particlesCheckbox) {
particlesCheckbox.addEventListener('change', () => {
applyParticlesSetting(particlesCheckbox.checked);
});
}
// Worker orbs toggle — apply immediately on change
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) {
workerOrbsCheckbox.addEventListener('change', () => {
applyWorkerOrbsSetting(workerOrbsCheckbox.checked);
});
}
// Reduce effects toggle — apply immediately on change
const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceEffectsCheckbox) {
reduceEffectsCheckbox.addEventListener('change', () => {
applyReduceEffects(reduceEffectsCheckbox.checked);
});
}
}
function applyReduceEffects(enabled) {
if (enabled) {
document.body.classList.add('reduce-effects');
} else {
document.body.classList.remove('reduce-effects');
}
window._reduceEffectsActive = enabled;
localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0');
// Reduce Visual Effects is a full performance switch: also halt the canvas
// animation loops (particles + worker orbs), not just CSS effects.
const pcanvas = document.getElementById('page-particles-canvas');
if (enabled) {
if (window.pageParticles) window.pageParticles.stop();
if (pcanvas) pcanvas.style.display = 'none';
if (window.workerOrbs) window.workerOrbs.setPage('_disabled');
} else {
// Restore only what the user's own toggles still allow.
const activePage = document.querySelector('.page.active');
const activeId = activePage ? activePage.id.replace('-page', '') : null;
if (window._particlesEnabled !== false) {
if (pcanvas) pcanvas.style.display = '';
if (window.pageParticles && activeId) window.pageParticles.setPage(activeId);
}
if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) {
window.workerOrbs.setPage(activeId);
}
}
}
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
(function () {
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
document.body.classList.add('reduce-effects');
window._reduceEffectsActive = true;
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage
const particlesSaved = localStorage.getItem('soulsync-particles');
if (particlesSaved === 'false') {
window._particlesEnabled = false;
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
}
// Bootstrap worker orbs setting from localStorage
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
}
})();
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
function notifyProfileContextChanged() {
window.dispatchEvent(new CustomEvent(PROFILE_CONTEXT_CHANGED_EVENT));
}
function setCurrentProfile(profile) {
currentProfile = profile;
updateProfileIndicator();
notifyProfileContextChanged();
}
// Temporary compatibility shim until existing profile rows are migrated to
// the current page ids.
const LEGACY_PROFILE_PAGE_ALIASES = {
downloads: 'search',
artists: 'search',
};
function normalizeProfilePageId(pageId) {
return LEGACY_PROFILE_PAGE_ALIASES[pageId] || pageId;
}
function normalizeProfilePageList(pageIds) {
if (!Array.isArray(pageIds)) return pageIds;
return pageIds.map(normalizeProfilePageId);
}
function getProfileHomePage() {
if (!currentProfile) return 'dashboard';
if (currentProfile.home_page) return normalizeProfilePageId(currentProfile.home_page);
return currentProfile.is_admin ? 'dashboard' : 'discover';
}
function isPageAllowed(pageId) {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
const normalizedPageId = normalizeProfilePageId(pageId);
if (normalizedPageId === 'help' || normalizedPageId === 'issues') return true;
if (normalizedPageId === 'settings') return currentProfile.is_admin;
if (normalizedPageId === 'artist-detail') {
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true;
return ap.includes('library') || ap.includes('search');
}
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true; // null = all pages
if (ap.includes(normalizedPageId)) return true;
return false;
}
function canDownload() {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
}
function getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
}
function activatePage(pageId, options = {}) {
const forceReload = options.forceReload === true;
const pageElement = document.getElementById(`${pageId}-page`);
const isPageVisible = pageElement ? pageElement.classList.contains('active') : false;
if (!forceReload && pageId === currentPage && isPageVisible) return;
showLegacyPage(pageId);
setActivePageChrome(pageId);
loadPageData(pageId);
}
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) {
el.className = 'profile-avatar';
}
el.style.background = profile.avatar_color || '#6366f1';
el.textContent = '';
if (profile.avatar_url) {
const img = document.createElement('img');
img.src = profile.avatar_url;
img.alt = profile.name;
img.className = 'profile-avatar-img';
img.onerror = () => {
img.remove();
el.textContent = profile.name.charAt(0).toUpperCase();
};
el.appendChild(img);
} else {
el.textContent = profile.name.charAt(0).toUpperCase();
}
}
async function initProfileSystem() {
try {
// Check if a session already has a profile selected
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
// Login mode: show the sign-in screen and defer everything else until
// the user authenticates.
if (currentData.login_required) {
showLoginScreen();
return false;
}
if (currentData.success && currentData.profile) {
setCurrentProfile(currentData.profile);
// Login mode → reveal the Sign out button in the profile bar.
if (currentData.login_mode) {
const lb = document.getElementById('logout-btn');
if (lb) lb.style.display = '';
}
// Check if launch PIN is required
if (currentData.launch_pin_required) {
showLaunchPinScreen();
return false; // Defer app init until PIN verified
}
return true; // Profile already selected, skip picker
}
// Fetch all profiles
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
if (profiles.length === 0) {
// No profiles yet — auto-select admin profile 1
await selectProfile(1);
return true;
}
if (profiles.length === 1) {
// Only one profile — always auto-select (PIN only matters with multiple profiles)
await selectProfile(profiles[0].id);
// Re-check for launch PIN after auto-select
const recheck = await fetch('/api/profiles/current');
const recheckData = await recheck.json();
if (recheckData.launch_pin_required) {
showLaunchPinScreen();
return false;
}
return true;
}
// Multiple profiles or PIN required — show picker
showProfilePicker(profiles);
return false; // App init deferred until profile selected
} catch (e) {
console.error('Profile init error:', e);
return true; // Fall through to normal init
}
}
// ── Login Screen (username/password mode) ──────────────────────────────
function showLoginScreen() {
const overlay = document.getElementById('login-overlay');
if (!overlay) return;
// Hide the entire app while locked, so removing the overlay (Safari "Hide
// Distracting Items", devtools) reveals nothing — not even the empty chrome.
// initApp() reveals it again on a successful sign-in (#852).
document.body.classList.add('app-locked');
overlay.style.display = 'flex';
const u = document.getElementById('login-username');
if (u) setTimeout(() => u.focus(), 50);
}
async function submitLogin() {
const username = (document.getElementById('login-username')?.value || '').trim();
const password = document.getElementById('login-password')?.value || '';
const errEl = document.getElementById('login-error');
const btn = document.getElementById('login-submit');
const showErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } };
if (errEl) errEl.style.display = 'none';
if (!username || !password) { showErr('Enter your username and password'); return; }
if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; }
try {
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (data.success) {
window.location.reload(); // authenticated → reload into the app
} else {
showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Sign in failed'));
if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; }
}
} catch (e) {
showErr('Connection error');
if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; }
}
}
async function soulsyncLogout() {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { /* reload anyway */ }
window.location.reload();
}
function showLoginRecovery() {
const entry = document.getElementById('login-entry');
const rec = document.getElementById('login-recovery');
if (entry) entry.style.display = 'none';
if (rec) rec.style.display = 'block';
const u = document.getElementById('recovery-username');
const lu = document.getElementById('login-username');
if (u && lu && lu.value) u.value = lu.value;
const errEl = document.getElementById('recovery-error');
if (errEl) errEl.style.display = 'none';
}
function showLoginEntry() {
const entry = document.getElementById('login-entry');
const rec = document.getElementById('login-recovery');
if (rec) rec.style.display = 'none';
if (entry) entry.style.display = 'block';
}
async function fetchRecoveryQuestion() {
const username = (document.getElementById('recovery-username')?.value || '').trim();
const errEl = document.getElementById('recovery-error');
const section = document.getElementById('recovery-answer-section');
const qText = document.getElementById('recovery-question-text');
const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } };
if (errEl) errEl.style.display = 'none';
if (!username) { showErr('Enter your username'); return; }
try {
const res = await fetch('/api/auth/recovery-question?username=' + encodeURIComponent(username));
const data = await res.json();
if (data.success && data.question) {
if (qText) qText.textContent = data.question;
if (section) section.style.display = 'block';
} else {
showErr('No recovery question is set for that account.');
}
} catch (e) { showErr('Connection error'); }
}
async function submitRecoveryReset() {
const username = (document.getElementById('recovery-username')?.value || '').trim();
const answer = document.getElementById('recovery-answer')?.value || '';
const newPassword = document.getElementById('recovery-new-password')?.value || '';
const confirmPassword = document.getElementById('recovery-new-password-confirm')?.value || '';
const errEl = document.getElementById('recovery-error');
const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } };
if (errEl) errEl.style.display = 'none';
if (!answer || !newPassword) { showErr('Enter your answer and a new password'); return; }
if (newPassword.length < 6) { showErr('New password must be at least 6 characters'); return; }
if (newPassword !== confirmPassword) { showErr('Passwords do not match'); return; }
try {
const res = await fetch('/api/auth/recovery-reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, answer, new_password: newPassword }),
});
const data = await res.json();
if (data.success) { window.location.reload(); }
else { showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Reset failed')); }
} catch (e) { showErr('Connection error'); }
}
// ── Launch PIN Lock Screen ─────────────────────────────────────────────
function showLaunchPinScreen() {
const overlay = document.getElementById('launch-pin-overlay');
if (!overlay) return;
// Hide the whole app while locked — bypassing the overlay reveals nothing (#852).
document.body.classList.add('app-locked');
overlay.style.display = 'flex';
const input = document.getElementById('launch-pin-input');
const submit = document.getElementById('launch-pin-submit');
const error = document.getElementById('launch-pin-error');
input.value = '';
error.style.display = 'none';
setTimeout(() => input.focus(), 100);
const doSubmit = async () => {
const pin = input.value.trim();
if (!pin) return;
submit.disabled = true;
submit.textContent = 'Verifying...';
try {
const res = await fetch('/api/profiles/verify-launch-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await res.json();
if (data.success) {
// Server session flag set by verify endpoint — consumed on next /api/profiles/current call
overlay.style.display = 'none';
initApp(); // Now safe to load the full app
} else {
error.textContent = data.error || 'Invalid PIN';
error.style.display = 'block';
input.value = '';
input.focus();
// Shake animation
overlay.querySelector('.launch-pin-container').classList.add('shake');
setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500);
}
} catch (e) {
error.textContent = 'Connection error';
error.style.display = 'block';
}
submit.disabled = false;
submit.textContent = 'Unlock';
};
// Remove old listeners to prevent stacking
const newSubmit = submit.cloneNode(true);
submit.parentNode.replaceChild(newSubmit, submit);
newSubmit.addEventListener('click', doSubmit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSubmit();
});
}
// ── Security Settings Helpers ──────────────────────────────────────────
async function saveLoginPassword() {
const input = document.getElementById('security-login-password');
const confirmInput = document.getElementById('security-login-password-confirm');
const msg = document.getElementById('security-login-password-msg');
const password = input?.value || '';
const confirm = confirmInput?.value || '';
const show = (text, ok) => {
if (!msg) return;
msg.textContent = text;
msg.style.color = ok ? '#4caf50' : '#ff5252';
msg.style.display = 'block';
};
if (!password || password.length < 6) { show('Password must be at least 6 characters', false); return; }
if (password !== confirm) { show('Passwords do not match', false); return; }
try {
const res = await fetch('/api/profiles/1/set-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (data.success) {
show('Admin login password saved', true);
if (input) { input.value = ''; input.placeholder = 'Enter a new password to change it'; }
if (confirmInput) confirmInput.value = '';
updateRequireLoginGate(true); // Step 1 done → unlock Step 3
const st = document.getElementById('security-login-password-status');
if (st) st.style.display = 'block';
}
else show(data.error || 'Failed to save password', false);
} catch (e) { show('Connection error', false); }
}
// Lock/unlock the "Require login" toggle based on whether the admin has a
// password — makes the prerequisite (anti-lockout) visible instead of a
// surprise 400 on save.
function updateRequireLoginGate(hasPassword) {
const toggle = document.getElementById('security-require-login');
const wrap = document.getElementById('security-login-toggle-wrap');
const help = document.getElementById('security-require-login-help');
if (!toggle) return;
toggle.disabled = !hasPassword;
if (!hasPassword) toggle.checked = false;
if (wrap) wrap.classList.toggle('security-locked', !hasPassword);
if (help) {
help.innerHTML = hasPassword
? 'Replaces the profile picker + PIN with a sign-in screen. Best for instances exposed to the internet.'
: '🔒 Set the admin password in Step 1 first — then you can turn this on.';
}
}
// Reflect already-saved login credentials. Passwords are never sent to the
// browser, so instead of an empty field (which looks unset after a refresh) we
// show that one is set and pre-fill the saved recovery question.
function applyLoginSavedState(profile) {
const hasPassword = profile?.has_password || false;
const hasRecovery = profile?.has_recovery || false;
const question = profile?.recovery_question || '';
const pwStatus = document.getElementById('security-login-password-status');
const pwField = document.getElementById('security-login-password');
const pwConfirm = document.getElementById('security-login-password-confirm');
if (pwStatus) pwStatus.style.display = hasPassword ? 'block' : 'none';
if (hasPassword) {
if (pwField) pwField.placeholder = 'Enter a new password to change it';
if (pwConfirm) pwConfirm.placeholder = 'Confirm new password';
}
const recStatus = document.getElementById('security-recovery-status');
const recSel = document.getElementById('security-recovery-question');
const recCustom = document.getElementById('security-recovery-custom');
const recAnswer = document.getElementById('security-recovery-answer');
if (recStatus) {
recStatus.style.display = hasRecovery ? 'block' : 'none';
recStatus.textContent = hasRecovery
? ('✓ Recovery question saved' + (question ? ': “' + question + '”' : ''))
: '';
}
if (hasRecovery) {
if (recSel && question) {
recSel.value = question; // preset options default value = their text
if (recSel.value !== question) { // not a preset → custom question
recSel.value = '__custom__';
if (recCustom) { recCustom.style.display = 'block'; recCustom.value = question; }
}
}
if (recAnswer) recAnswer.placeholder = 'Enter a new answer to change it';
}
}
function handleRecoveryQuestionChange() {
const sel = document.getElementById('security-recovery-question');
const custom = document.getElementById('security-recovery-custom');
if (sel && custom) custom.style.display = (sel.value === '__custom__') ? 'block' : 'none';
}
async function saveRecoveryQuestion() {
const sel = document.getElementById('security-recovery-question');
const custom = document.getElementById('security-recovery-custom');
const answer = document.getElementById('security-recovery-answer')?.value || '';
const msg = document.getElementById('security-recovery-msg');
const show = (text, ok) => {
if (!msg) return;
msg.textContent = text;
msg.style.color = ok ? '#4caf50' : '#ff5252';
msg.style.display = 'block';
};
let question = sel?.value || '';
if (question === '__custom__') question = (custom?.value || '').trim();
if (!question) { show('Pick or type a question', false); return; }
if (!answer.trim()) { show('Enter an answer', false); return; }
try {
const res = await fetch('/api/profiles/1/set-recovery', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, answer }),
});
const data = await res.json();
if (data.success) {
show('Recovery question saved', true);
const a = document.getElementById('security-recovery-answer');
if (a) { a.value = ''; a.placeholder = 'Enter a new answer to change it'; }
const rst = document.getElementById('security-recovery-status');
if (rst) { rst.style.display = 'block'; rst.textContent = '✓ Recovery question saved: “' + question + '”'; }
}
else show(data.error || 'Failed to save', false);
} catch (e) { show('Connection error', false); }
}
async function saveSecurityPin() {
const pin = document.getElementById('security-new-pin').value;
const confirm = document.getElementById('security-confirm-pin').value;
const msg = document.getElementById('security-pin-msg');
if (!pin || pin.length < 4) {
msg.textContent = 'PIN must be at least 4 characters';
msg.style.display = 'block';
msg.style.color = '#ff5252';
return;
}
if (pin !== confirm) {
msg.textContent = 'PINs do not match';
msg.style.display = 'block';
msg.style.color = '#ff5252';
return;
}
try {
const res = await fetch('/api/profiles/1/set-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await res.json();
if (data.success) {
msg.textContent = 'PIN saved! You can now enable the lock screen.';
msg.style.color = '#4caf50';
msg.style.display = 'block';
// Update UI — hide setup, show change, enable toggle
document.getElementById('security-pin-setup').style.display = 'none';
document.getElementById('security-change-pin-section').style.display = 'block';
document.getElementById('security-require-pin').disabled = false;
// Clear inputs
document.getElementById('security-new-pin').value = '';
document.getElementById('security-confirm-pin').value = '';
} else {
msg.textContent = data.error || 'Failed to save PIN';
msg.style.color = '#ff5252';
msg.style.display = 'block';
}
} catch (e) {
msg.textContent = 'Connection error';
msg.style.color = '#ff5252';
msg.style.display = 'block';
}
}
function handleSecurityPinToggle(checkbox) {
// If trying to enable but no PIN, show the setup section
if (checkbox.checked) {
const setupSection = document.getElementById('security-pin-setup');
if (setupSection.style.display !== 'none' || checkbox.disabled) {
checkbox.checked = false;
setupSection.style.display = 'block';
document.getElementById('security-new-pin').focus();
return;
}
}
// Auto-save this setting
saveSettings(true);
}
function showChangeSecurityPin() {
document.getElementById('security-pin-setup').style.display = 'block';
document.getElementById('security-new-pin').focus();
}
// ── Forgot PIN Recovery ────────────────────────────────────────────────
function showForgotPinView() {
document.getElementById('launch-pin-entry').style.display = 'none';
document.getElementById('launch-pin-recovery').style.display = 'block';
document.getElementById('launch-recovery-input').value = '';
document.getElementById('launch-recovery-error').style.display = 'none';
setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100);
}
function showPinEntryView() {
document.getElementById('launch-pin-recovery').style.display = 'none';
document.getElementById('launch-pin-entry').style.display = 'block';
setTimeout(() => document.getElementById('launch-pin-input').focus(), 100);
}
async function submitRecoveryCredential() {
const input = document.getElementById('launch-recovery-input');
const error = document.getElementById('launch-recovery-error');
const btn = document.getElementById('launch-recovery-submit');
const credential = input.value.trim();
if (!credential) return;
btn.disabled = true;
btn.textContent = 'Verifying...';
error.style.display = 'none';
try {
const res = await fetch('/api/profiles/reset-pin-via-credential', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential })
});
const data = await res.json();
if (data.success) {
sessionStorage.setItem('soulsync_pin_ok', '1');
document.getElementById('launch-pin-overlay').style.display = 'none';
initApp();
setTimeout(() => showToast('PIN cleared. You can set a new one in Settings → Advanced.', 'success'), 1000);
} else {
error.textContent = data.error || 'Credential not recognized';
error.style.display = 'block';
input.value = '';
input.focus();
document.getElementById('launch-pin-container').classList.add('shake');
setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500);
}
} catch (e) {
error.textContent = 'Connection error';
error.style.display = 'block';
}
btn.disabled = false;
btn.textContent = 'Verify & Reset PIN';
}
// ── Profile PIN Forgot Recovery ────────────────────────────────────────
function showProfileForgotPin() {
const dialog = document.getElementById('profile-pin-dialog');
const content = dialog.querySelector('.profile-pin-content');
// Store the profile ID we're recovering for
const profileName = document.getElementById('profile-pin-name').textContent;
// Replace dialog content with recovery form
content.dataset.prevHtml = content.innerHTML;
content.innerHTML = `
Reset PIN for ${profileName}
Enter any configured API credential (Spotify secret, Plex token, etc.)
`;
setTimeout(() => document.getElementById('profile-recovery-input').focus(), 100);
document.getElementById('profile-recovery-cancel').onclick = () => {
content.innerHTML = content.dataset.prevHtml;
};
document.getElementById('profile-recovery-submit').onclick = async () => {
const input = document.getElementById('profile-recovery-input');
const error = document.getElementById('profile-recovery-error');
const credential = input.value.trim();
if (!credential) return;
const btn = document.getElementById('profile-recovery-submit');
btn.disabled = true;
btn.textContent = 'Verifying...';
error.style.display = 'none';
try {
const res = await fetch('/api/profiles/reset-pin-via-credential', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, profile_id: dialog._profileId || 1 })
});
const data = await res.json();
if (data.success) {
dialog.style.display = 'none';
content.innerHTML = content.dataset.prevHtml;
showToast('PIN cleared. You can set a new one in Settings.', 'success');
// Re-try selecting the profile (now PIN-free)
if (dialog._profileId) selectProfile(dialog._profileId);
} else {
error.textContent = data.error || 'Credential not recognized';
error.style.display = 'block';
input.value = '';
input.focus();
}
} catch (e) {
error.textContent = 'Connection error';
error.style.display = 'block';
}
btn.disabled = false;
btn.textContent = 'Verify & Reset';
};
document.getElementById('profile-recovery-input').onkeydown = (e) => {
if (e.key === 'Enter') document.getElementById('profile-recovery-submit').click();
};
}
function showProfilePicker(profiles, canCancel = false) {
const overlay = document.getElementById('profile-picker-overlay');
const grid = document.getElementById('profile-picker-grid');
const actions = document.getElementById('profile-picker-actions');
grid.innerHTML = '';
profiles.forEach(p => {
const card = document.createElement('div');
card.className = 'profile-picker-card';
const avatarEl = document.createElement('div');
renderProfileAvatar(avatarEl, p);
card.appendChild(avatarEl);
const nameEl = document.createElement('span');
nameEl.className = 'profile-name';
nameEl.textContent = p.name;
card.appendChild(nameEl);
if (p.is_admin) {
const badge = document.createElement('span');
badge.className = 'profile-badge';
badge.textContent = 'Admin';
card.appendChild(badge);
}
card.onclick = () => handleProfileClick(p);
grid.appendChild(card);
});
// Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected)
const isAdmin = currentProfile ? currentProfile.is_admin : false;
const manageBtn = document.getElementById('manage-profiles-btn');
if (isAdmin) {
actions.style.display = '';
if (manageBtn) {
manageBtn.textContent = 'Manage Profiles';
// Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here)
manageBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'flex';
loadProfileManageList();
};
}
} else if (currentProfile && canCancel) {
// Non-admin with an active profile: show "My Profile" to edit own settings
actions.style.display = '';
if (manageBtn) {
manageBtn.textContent = 'My Profile';
manageBtn.onclick = () => showSelfEditForm();
}
} else {
actions.style.display = 'none';
}
// Show/remove cancel button when opened from sidebar indicator
let cancelBtn = overlay.querySelector('.profile-picker-cancel');
if (cancelBtn) cancelBtn.remove();
if (canCancel) {
cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => hideProfilePicker();
actions.parentElement.appendChild(cancelBtn);
}
overlay.style.display = 'flex';
document.querySelector('.main-container').style.display = 'none';
}
async function handleProfileClick(profile) {
// Fetch profile count — PIN only matters with multiple profiles
let profileCount = 1;
try {
const r = await fetch('/api/profiles');
const d = await r.json();
profileCount = (d.profiles || []).length;
} catch (e) { }
if (profile.has_pin && profileCount > 1) {
showPinDialog(profile);
} else {
const wasSwitching = !!currentProfile;
await selectProfile(profile.id);
if (wasSwitching) {
window.location.reload();
return;
}
hideProfilePicker();
initApp();
}
}
function showPinDialog(profile) {
const dialog = document.getElementById('profile-pin-dialog');
const avatar = document.getElementById('profile-pin-avatar');
const nameEl = document.getElementById('profile-pin-name');
const errorEl = document.getElementById('profile-pin-error');
const oldInput = document.getElementById('profile-pin-input');
const oldSubmit = document.getElementById('profile-pin-submit');
const oldCancel = document.getElementById('profile-pin-cancel');
// Replace controls on every open so stale listeners from a previous
// profile cannot submit the new PIN against the old profile id.
const input = oldInput.cloneNode(true);
const submit = oldSubmit.cloneNode(true);
const cancel = oldCancel.cloneNode(true);
oldInput.parentNode.replaceChild(input, oldInput);
oldSubmit.parentNode.replaceChild(submit, oldSubmit);
oldCancel.parentNode.replaceChild(cancel, oldCancel);
renderProfileAvatar(avatar, profile);
nameEl.textContent = profile.name;
input.value = '';
errorEl.style.display = 'none';
dialog._profileId = profile.id;
dialog.style.display = 'flex';
setTimeout(() => input.focus(), 100);
const wasSwitching = !!currentProfile;
const handleSubmit = async () => {
const pin = input.value;
if (!pin) return;
submit.disabled = true;
submit.textContent = 'Verifying...';
try {
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, pin })
});
const data = await res.json();
if (data.success) {
cleanup();
if (wasSwitching) {
window.location.reload();
return;
}
dialog.style.display = 'none';
hideProfilePicker();
setCurrentProfile(data.profile);
initApp();
return;
} else {
errorEl.textContent = data.error || 'Invalid PIN';
errorEl.style.display = '';
input.value = '';
input.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.style.display = '';
}
submit.disabled = false;
submit.textContent = 'Submit';
};
const handleCancel = () => {
dialog.style.display = 'none';
cleanup();
};
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
const cleanup = () => {
submit.removeEventListener('click', handleSubmit);
cancel.removeEventListener('click', handleCancel);
input.removeEventListener('keydown', handleKeydown);
};
submit.addEventListener('click', handleSubmit);
cancel.addEventListener('click', handleCancel);
input.addEventListener('keydown', handleKeydown);
}
async function selectProfile(profileId) {
try {
const oldProfileId = currentProfile ? currentProfile.id : null;
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
});
const data = await res.json();
if (data.success) {
setCurrentProfile(data.profile);
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
}
// Invalidate ListenBrainz cache on profile switch (each profile has their own playlists)
_invalidateListenBrainzCache();
}
return data.success;
} catch (e) {
console.error('Error selecting profile:', e);
return false;
}
}
function hideProfilePicker() {
document.getElementById('profile-picker-overlay').style.display = 'none';
document.querySelector('.main-container').style.display = 'flex';
}
function updateProfileIndicator() {
const indicator = document.getElementById('profile-indicator');
if (!currentProfile || !indicator) return;
const avatar = document.getElementById('profile-indicator-avatar');
const name = document.getElementById('profile-indicator-name');
renderProfileAvatar(avatar, currentProfile);
name.textContent = currentProfile.name;
indicator.style.display = 'flex';
// Service Status quick-switch is admin-only — drop the clickable affordance
// for non-admins so it doesn't look interactive.
const statusSection = document.querySelector('.status-section--clickable');
if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin);
// My Accounts (per-profile streaming OAuth) and My Settings (per-profile
// server library) are inert for admin — admin uses the global app account
// for every service and the full Settings page. Hide both for admin; keep
// them for non-admins, who actually get a connect/library UI.
const myAccountsBtn = document.getElementById('my-accounts-btn');
const personalSettingsBtn = document.getElementById('personal-settings-btn');
if (myAccountsBtn) myAccountsBtn.style.display = currentProfile.is_admin ? 'none' : '';
if (personalSettingsBtn) personalSettingsBtn.style.display = currentProfile.is_admin ? 'none' : '';
indicator.onclick = async () => {
const res = await fetch('/api/profiles');
const data = await res.json();
if (data.profiles && data.profiles.length > 0) {
showProfilePicker(data.profiles, true);
}
};
// Filter sidebar pages based on profile permissions
document.querySelectorAll('.nav-button[data-page]').forEach(btn => {
const page = btn.getAttribute('data-page');
if (page === 'hydrabase') return; // Managed by dev mode toggle
if (page === 'settings') {
// Settings always gated by is_admin
btn.style.display = currentProfile.is_admin ? '' : 'none';
} else if (page === 'help' || page === 'issues') {
btn.style.display = ''; // Always visible
} else if (currentProfile.id === 1) {
btn.style.display = ''; // Root admin sees all
} else {
const ap = currentProfile.allowed_pages;
btn.style.display = (!ap || ap.includes(page)) ? '' : 'none';
}
});
// Toggle download capability
if (canDownload()) {
document.body.classList.remove('downloads-disabled');
} else {
document.body.classList.add('downloads-disabled');
}
}
// =====================
// PERSONAL SETTINGS MODAL
// =====================
async function openPersonalSettings() {
const overlay = document.getElementById('personal-settings-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
const body = document.getElementById('personal-settings-body');
body.innerHTML = '
Loading...
';
try {
body.innerHTML = '';
const isNonAdmin = currentProfile && !currentProfile.is_admin;
// Streaming-account connections now live in the My Accounts modal (the ♫
// button). Personal Settings keeps only the per-profile server library.
if (isNonAdmin) {
const serverTab = document.createElement('div');
serverTab.style.padding = '18px 22px 22px';
serverTab.innerHTML = '