Merge pull request #943 from nick2000713/ui/settings-page-cleanup

UI/settings page cleanup
This commit is contained in:
BoulderBadgeDad 2026-06-28 16:05:38 -07:00 committed by GitHub
commit 6a388c43fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1384 additions and 812 deletions

View file

@ -303,6 +303,83 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
def _valid_hex_color(value, fallback='#1db954'):
value = str(value or '').strip()
return value if re.fullmatch(r'#[0-9a-fA-F]{6}', value) else fallback
def _hex_to_rgb(hex_color):
color = _valid_hex_color(hex_color)
return tuple(int(color[i:i + 2], 16) for i in (1, 3, 5))
def _rgb_to_hsl(r, g, b):
rn, gn, bn = r / 255, g / 255, b / 255
max_c = max(rn, gn, bn)
min_c = min(rn, gn, bn)
lightness = (max_c + min_c) / 2
if max_c == min_c:
return 0, 0, lightness
delta = max_c - min_c
saturation = delta / (2 - max_c - min_c) if lightness > 0.5 else delta / (max_c + min_c)
if max_c == rn:
hue = ((gn - bn) / delta + (6 if gn < bn else 0)) / 6
elif max_c == gn:
hue = ((bn - rn) / delta + 2) / 6
else:
hue = ((rn - gn) / delta + 4) / 6
return hue, saturation, lightness
def _hsl_to_rgb(hue, saturation, lightness):
if saturation == 0:
value = round(lightness * 255)
return value, value, value
def hue_to_rgb(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
q = lightness * (1 + saturation) if lightness < 0.5 else lightness + saturation - lightness * saturation
p = 2 * lightness - q
return (
round(hue_to_rgb(p, q, hue + 1 / 3) * 255),
round(hue_to_rgb(p, q, hue) * 255),
round(hue_to_rgb(p, q, hue - 1 / 3) * 255),
)
def _initial_appearance_context():
preset = config_manager.get('ui_appearance.accent_preset', '#1db954')
custom = config_manager.get('ui_appearance.accent_color', '#1db954')
accent = _valid_hex_color(custom if preset == 'custom' else preset)
particles_enabled = config_manager.get('ui_appearance.particles_enabled', False) is True
worker_orbs_enabled = config_manager.get('ui_appearance.worker_orbs_enabled', True) is not False
reduce_effects = config_manager.get('ui_appearance.reduce_effects', False) is True
r, g, b = _hex_to_rgb(accent)
hue, saturation, lightness = _rgb_to_hsl(r, g, b)
light = _hsl_to_rgb(hue, saturation, min(lightness + 0.16, 0.95))
neon = _hsl_to_rgb(hue, min(saturation + 0.1, 1.0), min(lightness + 0.30, 0.95))
return {
'initial_accent_color': accent,
'initial_accent_rgb': f'{r}, {g}, {b}',
'initial_accent_light_rgb': f'{light[0]}, {light[1]}, {light[2]}',
'initial_accent_neon_rgb': f'{neon[0]}, {neon[1]}, {neon[2]}',
'initial_particles_enabled': particles_enabled,
'initial_worker_orbs_enabled': worker_orbs_enabled,
'initial_reduce_effects': reduce_effects,
}
@app.context_processor
def _inject_static_cache_bust():
@ -319,7 +396,7 @@ def _inject_static_cache_bust():
static_v = str(max(mtimes))
except Exception:
static_v = _STATIC_CACHE_BUST
return {'static_v': static_v}
return {'static_v': static_v, **_initial_appearance_context()}
@app.context_processor

File diff suppressed because it is too large Load diff

View file

@ -102,9 +102,9 @@ function ImportOptions() {
/>
<span
id="import-quality-filter-label"
title="Only import tracks that meet your quality profile; otherwise import everything regardless of quality."
title="Checks imported files against your Quality Profile only. Turning this off imports files regardless of format/bitrate/bit depth/sample rate; AcoustID verification is separate and is not skipped."
>
Quality check on import
Quality profile check on import
</span>
</div>
<div className={styles.importOption}>

View file

@ -254,16 +254,28 @@ function applyReduceEffects(enabled) {
}
}
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
const reduceEffectsSaved = localStorage.getItem('soulsync-reduce-effects');
if (reduceEffectsSaved === '1') {
document.body.classList.add('reduce-effects');
window._reduceEffectsActive = true;
} else if (reduceEffectsSaved === '0') {
document.body.classList.remove('reduce-effects');
window._reduceEffectsActive = false;
} else if (window._reduceEffectsActive) {
document.body.classList.add('reduce-effects');
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage — OFF by default (continuous
// full-page canvas = real GPU cost); only on when the user explicitly enabled it.
const particlesSaved = localStorage.getItem('soulsync-particles');
window._particlesEnabled = (particlesSaved === 'true');
if (particlesSaved === 'true') {
window._particlesEnabled = true;
} else if (particlesSaved === 'false') {
window._particlesEnabled = false;
} else if (typeof window._particlesEnabled !== 'boolean') {
window._particlesEnabled = false;
}
if (!window._particlesEnabled) {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
@ -272,9 +284,41 @@ function applyReduceEffects(enabled) {
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
} else if (workerOrbsSaved === 'true') {
window._workerOrbsEnabled = true;
} else if (typeof window._workerOrbsEnabled !== 'boolean') {
window._workerOrbsEnabled = true;
}
})();
async function bootstrapServerAppearanceSettings() {
try {
const response = await fetch('/api/settings', { credentials: 'same-origin' });
const settings = await response.json();
if (!response.ok || !settings || typeof settings !== 'object' || settings.error) return;
const appearance = settings.ui_appearance || {};
const preset = appearance.accent_preset || '#1db954';
const custom = appearance.accent_color || '#1db954';
const accent = preset === 'custom' ? custom : preset;
applyAccentColor(accent);
if (Object.prototype.hasOwnProperty.call(appearance, 'particles_enabled')) {
applyParticlesSetting(appearance.particles_enabled !== false);
}
if (Object.prototype.hasOwnProperty.call(appearance, 'worker_orbs_enabled')) {
applyWorkerOrbsSetting(appearance.worker_orbs_enabled !== false);
}
if (localStorage.getItem('soulsync-reduce-effects') === null) {
applyReduceEffects(appearance.reduce_effects === true);
}
} catch (error) {
console.warn('Could not bootstrap appearance settings:', error);
}
}
bootstrapServerAppearanceSettings();
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';

View file

@ -40,15 +40,31 @@ async function copyAddress(address, cryptoName) {
let settingsAutoSaveTimer = null;
// The "Only import AcoustID-verified tracks" toggle (under Quality Profile) is
// meaningless when AcoustID itself is off — only show it when verification is on.
// The "Only allow AcoustID-verified tracks" toggle lives in Audio Verification
// and is always shown (its help notes it needs AcoustID enabled), so users can
// always find it. Kept as a no-op for any existing callers.
function syncAcoustidRequireVerifiedVisibility() {
const group = document.getElementById('acoustid-require-verified-group');
const enabled = document.getElementById('acoustid-enabled');
if (group) group.style.display = (enabled && enabled.checked) ? '' : 'none';
if (group) group.style.display = '';
}
window.syncAcoustidRequireVerifiedVisibility = syncAcoustidRequireVerifiedVisibility;
// Retry Logic: the two numeric rows are only meaningful when their parent toggle
// is on — hide them otherwise. "Retries per query" needs Exhaustive retry;
// "Minimum matching mismatches" needs the version-mismatch fallback.
function syncRetryConditionalRows() {
const pairs = [
['retry-exhaustive', 'retries-per-query-row'],
['accept-version-mismatch-fallback', 'version-mismatch-min-count-row'],
];
for (const [toggleId, rowId] of pairs) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
if (row) row.style.display = (toggle && toggle.checked) ? '' : 'none';
}
}
window.syncRetryConditionalRows = syncRetryConditionalRows;
function debouncedAutoSaveSettings() {
// Ignore changes made while the page is programmatically populating its
// fields on load — those aren't user edits and must not trigger a full
@ -417,6 +433,11 @@ function switchSettingsTab(tab) {
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch (e) { }
}
// First time the Downloads tab is shown, auto-probe source status so the
// dots reflect real connection state without a manual "Test all sources".
if (tab === 'downloads' && typeof autoTestSourcesOnce === 'function') {
autoTestSourcesOnce();
}
// Initialize live log viewer when switching to Logs tab
if (tab === 'logs') {
_logViewerInit();
@ -659,6 +680,126 @@ const ALBUM_LEVEL_HYBRID_SOURCES = new Set(['soulseek', 'torrent', 'usenet']);
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false, torrent: false, usenet: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
// In hybrid mode, only one source's config panel is shown at a time (clicked
// open from its row), so the long per-source config blocks don't all stack up.
let _expandedHybridSource = null;
function toggleHybridSourceConfig(srcId) {
_expandedHybridSource = (_expandedHybridSource === srcId) ? null : srcId;
buildHybridSourceList();
updateDownloadSourceUI();
// Bring the freshly opened config panel into view.
if (_expandedHybridSource) {
const map = {
soulseek: 'soulseek-settings-container', youtube: 'youtube-settings-container',
tidal: 'tidal-download-settings-container', qobuz: 'qobuz-settings-container',
hifi: 'hifi-download-settings-container', deezer_dl: 'deezer-download-settings-container',
amazon: 'amazon-download-settings-container', lidarr: 'lidarr-download-settings-container',
soundcloud: 'soundcloud-download-settings-container', torrent: 'prowlarr-source-redirect',
usenet: 'prowlarr-source-redirect',
};
const el = document.getElementById(map[_expandedHybridSource]);
if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 60);
}
}
// ── Per-source live connection status (shown as a dot in the hybrid list and
// driven by the "Test all sources" button). srcId -> 'unknown'|'testing'|'ok'|'fail'|'na'
let _hybridSourceStatus = {};
async function _ssJson(url, opts) {
const r = await fetch(url, opts);
return await r.json();
}
function _ssTestConn(service) {
return _ssJson(API.testConnection || '/api/test-connection', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
}).then(j => !!j.success);
}
// Each probe returns a boolean (connected/ok). Endpoints mirror the per-source
// "Test Connection" buttons so the results match what those buttons would show.
const HYBRID_SOURCE_PROBE = {
soulseek: () => _ssTestConn('soulseek'),
tidal: () => _ssTestConn('tidal'),
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
lidarr: () => _ssTestConn('lidarr'),
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
torrent: () => _ssTestConn('torrent_client'),
usenet: () => _ssTestConn('usenet_client'),
youtube: () => Promise.resolve(true), // no auth required
};
// Configured metadata / server connections that support a generic test.
const CONNECTION_TEST_SERVICES = ['spotify', 'server', 'tidal', 'qobuz', 'lastfm', 'genius', 'listenbrainz', 'acoustid', 'discogs'];
async function testAllSources(opts = {}) {
const silent = opts.silent === true; // no toast / no connection sweep (used for auto-run on load)
const btn = document.getElementById('test-all-sources-btn');
if (btn && !silent) { btn.disabled = true; btn.dataset._label = btn.textContent; btn.textContent = 'Testing…'; }
// Which download sources to test: the enabled hybrid sources, or the single
// selected source in non-hybrid mode.
const mode = document.getElementById('download-source-mode')?.value;
const sources = new Set();
if (mode === 'hybrid') {
(typeof getHybridOrder === 'function' ? getHybridOrder() : []).forEach(s => sources.add(s));
} else if (mode) {
sources.add(mode);
}
if (sources.size === 0) sources.add('soulseek');
// Torrent/Usenet downloads go through Prowlarr — its connection must be
// established first or those source tests fail. Probe Prowlarr up front.
if (sources.has('torrent') || sources.has('usenet')) {
try { await _ssTestConn('prowlarr'); } catch (e) { /* surfaced via the per-source test below */ }
}
for (const id of sources) _hybridSourceStatus[id] = 'testing';
buildHybridSourceList();
let ok = 0, fail = 0;
for (const id of sources) {
const probe = HYBRID_SOURCE_PROBE[id];
if (!probe) { _hybridSourceStatus[id] = 'na'; continue; }
try { const good = await probe(); _hybridSourceStatus[id] = good ? 'ok' : 'fail'; good ? ok++ : fail++; }
catch (e) { _hybridSourceStatus[id] = 'fail'; fail++; }
buildHybridSourceList();
}
// Also test the metadata / server connections the user has configured
// (skipped on the silent auto-run to keep page load light).
let connOk = 0, connFail = 0;
if (!silent) {
try {
const cfg = await _ssJson('/api/settings/config-status');
for (const svc of CONNECTION_TEST_SERVICES) {
const configured = svc === 'server' ? true : (cfg && cfg[svc] && cfg[svc].configured);
if (!configured) continue;
try { const good = await _ssTestConn(svc); good ? connOk++ : connFail++; } catch (e) { connFail++; }
}
} catch (e) { /* config-status unavailable — skip connection sweep */ }
}
if (btn && !silent) { btn.disabled = false; btn.textContent = btn.dataset._label || 'Test all sources'; }
if (!silent) {
const parts = [`sources ${ok}${fail ? ' / ' + fail + '✗' : ''}`];
if (connOk || connFail) parts.push(`connections ${connOk}${connFail ? ' / ' + connFail + '✗' : ''}`);
showToast('Tested ' + parts.join(', '), (fail || connFail) ? 'error' : 'success');
}
}
window.testAllSources = testAllSources;
// Auto-populate the source status dots once after the page settles, so they
// reflect real state after a restart without the user having to click Test.
let _sourcesAutoTested = false;
function autoTestSourcesOnce() {
if (_sourcesAutoTested) return;
_sourcesAutoTested = true;
setTimeout(() => { try { testAllSources({ silent: true }); } catch (e) { } }, 1200);
}
function buildHybridSourceList() {
const container = document.getElementById('hybrid-source-list');
@ -689,11 +830,16 @@ function buildHybridSourceList() {
const sourceLevelBadge = `<span class="hybrid-source-badge hybrid-source-badge-${sourceLevelClass}" title="${sourceLevelTitle}">${sourceLevel}</span>`;
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
const isExpanded = enabled && _expandedHybridSource === srcId;
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}${isExpanded ? ' config-open' : ''}`;
item.draggable = true;
item.dataset.sourceId = srcId;
// The name + a config chevron open this source's settings panel inline
// (only one at a time), so the long config blocks don't all stack up.
const clickConfig = enabled ? `onclick="toggleHybridSourceConfig('${srcId}')"` : '';
item.innerHTML = `
<span class="hybrid-source-handle" title="Drag to reorder"></span>
<span class="hybrid-source-arrows">
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', -1)" title="Move up"></button>
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', 1)" title="Move down"></button>
@ -702,9 +848,11 @@ function buildHybridSourceList() {
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
}
<span class="hybrid-source-name">${src.name}</span>
<span class="hybrid-source-name" ${clickConfig} style="${enabled ? 'cursor:pointer;' : ''}">${src.name}</span>
${sourceLevelBadge}
<span class="hybrid-source-priority">${priorityNum}</span>
<span class="hybrid-source-status hss-${_hybridSourceStatus[srcId] || 'unknown'}" title="${({ unknown: 'Not tested yet', testing: 'Testing…', ok: 'Connected', fail: 'Connection failed', na: 'No connection test for this source' })[_hybridSourceStatus[srcId] || 'unknown']}"></span>
${enabled ? `<button class="hybrid-source-config-btn" ${clickConfig} title="Configure ${src.name}">⚙</button>` : ''}
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">
<span class="toggle-track"></span>
@ -718,10 +866,15 @@ function buildHybridSourceList() {
e.dataTransfer.setData('text/plain', srcId);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; });
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
container.querySelectorAll('.hybrid-source-item').forEach(el => el.classList.remove('drag-over'));
});
item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', (e) => {
e.preventDefault();
item.classList.remove('drag-over');
const draggedId = e.dataTransfer.getData('text/plain');
if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId);
});
@ -768,6 +921,8 @@ function _reorderHybridSource(draggedId, targetId) {
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
// If the source we just disabled had its config panel open, close it.
if (!enabled && _expandedHybridSource === srcId) _expandedHybridSource = null;
// Rebuild enabled order from visual order so priority matches position
if (_hybridVisualOrder) {
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
@ -1269,6 +1424,7 @@ async function loadSettingsData() {
document.getElementById('retries-per-query').value = settings.post_processing?.retries_per_query ?? 5;
document.getElementById('accept-version-mismatch-fallback').checked = settings.post_processing?.accept_version_mismatch_fallback === true;
document.getElementById('version-mismatch-min-count').value = settings.post_processing?.version_mismatch_min_count ?? 2;
if (typeof syncRetryConditionalRows === 'function') syncRetryConditionalRows();
// Load service master toggles
document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false;
document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false;
@ -1861,21 +2017,43 @@ function updateDownloadSourceUI() {
activeSources.add(mode);
}
soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none';
tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
if (amazonContainer) amazonContainer.style.display = activeSources.has('amazon') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
// In single-source mode the one config block is shown directly. In hybrid
// mode there can be many active sources, so we only reveal the one the user
// clicked open in the priority list (accordion-style) — no endless stack.
const isHybrid = mode === 'hybrid';
const showCfg = (src) => activeSources.has(src) && (!isHybrid || _expandedHybridSource === src);
soulseekContainer.style.display = showCfg('soulseek') ? 'block' : 'none';
tidalContainer.style.display = showCfg('tidal') ? 'block' : 'none';
qobuzContainer.style.display = showCfg('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = showCfg('youtube') ? 'block' : 'none';
hifiContainer.style.display = showCfg('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = showCfg('deezer_dl') ? 'block' : 'none';
if (amazonContainer) amazonContainer.style.display = showCfg('amazon') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = showCfg('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = showCfg('soundcloud') ? 'block' : 'none';
const prowlarrRedirect = document.getElementById('prowlarr-source-redirect');
if (prowlarrRedirect) {
const showProwlarr = activeSources.has('torrent') || activeSources.has('usenet');
const showProwlarr = showCfg('torrent') || showCfg('usenet');
prowlarrRedirect.style.display = showProwlarr ? 'block' : 'none';
}
// Indexers & Downloaders section: only relevant when a torrent or usenet
// source is actually selected. Hide the whole intro + Prowlarr/Torrent/Usenet
// tiles otherwise (Soulseek/HiFi-only users never see them). The two client
// tiles are gated individually on their own source. Selection-based (not the
// hybrid expand state) — the tiles are full config sections, not accordion
// panels. Tab-gated so it never leaks onto another tab.
const onDownloadsTab = document.querySelector('.stg-tab.active')?.dataset.tab === 'downloads';
const torrentActive = activeSources.has('torrent');
const usenetActive = activeSources.has('usenet');
const indSection = document.getElementById('indexers-downloaders-section');
if (indSection) indSection.style.display = (onDownloadsTab && (torrentActive || usenetActive)) ? '' : 'none';
const torrentTile = document.getElementById('torrent-tile');
if (torrentTile) torrentTile.style.display = torrentActive ? '' : 'none';
const usenetTile = document.getElementById('usenet-tile');
if (usenetTile) usenetTile.style.display = usenetActive ? '' : 'none';
// Quality profile is now a GLOBAL system — the same ranked-target list
// drives every source (Soulseek, Tidal, Qobuz, HiFi, Deezer, …), so it is
// no longer Soulseek-gated. Show the whole collapsible tile whenever the
@ -1884,23 +2062,25 @@ function updateDownloadSourceUI() {
const qualityProfileTile = document.getElementById('quality-profile-tile');
if (qualityProfileTile) {
const activeTab = document.querySelector('.stg-tab.active');
const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
qualityProfileTile.style.display = onDownloadsTab ? '' : 'none';
const onQualityTab = activeTab && activeTab.dataset.tab === 'quality';
qualityProfileTile.style.display = onQualityTab ? '' : 'none';
}
if (activeSources.has('tidal')) {
// Only auto-probe a source's live status when its config panel is visible
// (always in single-source mode; only the opened one in hybrid mode).
if (showCfg('tidal')) {
checkTidalDownloadAuthStatus();
}
if (activeSources.has('qobuz')) {
if (showCfg('qobuz')) {
checkQobuzAuthStatus();
}
if (activeSources.has('hifi')) {
if (showCfg('hifi')) {
testHiFiConnection();
}
if (activeSources.has('amazon')) {
if (showCfg('amazon')) {
testAmazonConnection();
}
if (activeSources.has('soundcloud')) {
if (showCfg('soundcloud')) {
testSoundcloudConnection();
}
}
@ -2021,12 +2201,30 @@ function onSearchModeChange() {
// body is the immediate next element or sits after a control (e.g. a <select>),
// and regardless of any wrapping container.
function toggleSettingHelp(iconEl) {
// Locate the help body to toggle. Search order:
// 1) the next .setting-help-body sibling after the icon's row (icon + body
// both inside the same .form-group / .setting-row),
// 2) the element right after the enclosing .form-group (help wall that sits
// as a sibling just below the group — the common always-visible case).
const row = iconEl.closest('.setting-row') || iconEl;
let el = row.nextElementSibling;
while (el && !el.classList.contains('setting-help-body')) {
el = el.nextElementSibling;
}
if (el) el.hidden = !el.hidden;
if (!el) {
const fg = iconEl.closest('.form-group');
let sib = fg ? fg.nextElementSibling : null;
// Only accept an immediately-following help body — never reach across
// into the next setting/group.
if (sib && sib.classList.contains('setting-help-body')) el = sib;
}
if (el) {
el.hidden = !el.hidden;
// Reflect open state on the icon itself (filled badge) so it's clear
// which help panel is currently revealed.
const icon = iconEl.classList.contains('info-icon') ? iconEl : row.querySelector('.info-icon');
if (icon) icon.classList.toggle('open', !el.hidden);
}
}
function renderRankedTargets() {

View file

@ -3272,10 +3272,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.settings-section-body {
transition: all 0.2s ease;
/* Breathing room before the next tile header so an expanded tile's last
control (e.g. "+ Add Path") never sits flush against the next section. */
margin-bottom: 18px;
}
.settings-section-body.collapsed {
display: none;
margin-bottom: 0;
}
.settings-group {
@ -3392,79 +3396,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #a78bfa; /* Usenet — violet */
}
/* Lidarr-style intro hero card on Indexers & Downloaders tab */
.ind-hero {
padding: 18px 22px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.08), rgba(35, 35, 35, 0.6));
border: 1px solid rgba(var(--accent-rgb), 0.15);
}
.ind-hero-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: rgb(var(--accent-rgb));
margin-bottom: 12px;
}
.ind-hero-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
}
.ind-hero-step {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.ind-hero-step-num {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(var(--accent-rgb), 0.2);
color: rgb(var(--accent-rgb));
font-weight: 700;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ind-hero-arrow {
color: rgba(var(--accent-rgb), 0.55);
font-weight: 700;
}
.ind-hero-sub {
font-size: 12.5px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.55;
}
.ind-hero-warning {
margin-top: 14px;
padding: 12px 14px;
background: rgba(255, 165, 0, 0.06);
border: 1px solid rgba(255, 165, 0, 0.2);
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
}
.ind-hero-warning-title {
font-size: 12px;
font-weight: 700;
color: #ffb155;
letter-spacing: 0.4px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 0.3px;
margin-bottom: 6px;
}
@ -3942,23 +3886,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex: 0 0 auto;
font-size: 12px;
line-height: 1;
color: rgba(var(--accent-rgb), 0.85);
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
}
.info-icon:hover { color: rgba(var(--accent-rgb), 1); }
.setting-help-body { margin-top: 4px; }
/* Supported Formats */
.supported-formats {
color: rgba(255, 255, 255, 0.7);
@ -4346,213 +4273,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
background: rgba(99, 179, 237, 0.3);
}
.quality-tier {
margin-bottom: 16px;
padding: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
transition: all 0.2s ease;
}
.quality-tier:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.quality-tier-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.quality-tier-name {
color: rgba(255, 255, 255, 0.95);
font-size: 12px;
font-weight: 600;
}
.quality-tier-priority {
color: rgba(255, 255, 255, 0.45);
font-size: 10px;
font-weight: 500;
}
.quality-tier-sliders {
padding-left: 24px;
opacity: 1;
transition: opacity 0.3s ease;
}
.quality-tier-sliders.disabled {
opacity: 0.35;
pointer-events: none;
}
.flac-bit-depth-selector {
padding: 8px 12px;
transition: opacity 0.3s ease;
}
.flac-bit-depth-selector.disabled {
opacity: 0.35;
pointer-events: none;
}
.flac-bit-depth-selector label {
display: block;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
}
.bit-depth-buttons {
display: flex;
gap: 8px;
}
.bit-depth-btn {
flex: 1;
padding: 7px 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bit-depth-btn.active {
background: linear-gradient(135deg,
rgba(var(--accent-rgb), 0.2) 0%,
rgba(var(--accent-light-rgb), 0.12) 100%);
border-color: rgba(var(--accent-rgb), 0.4);
color: rgb(var(--accent-light-rgb));
font-weight: 600;
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.bit-depth-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: #ffffff;
transform: translateY(-1px);
}
.bit-depth-btn.active:hover {
background: linear-gradient(135deg,
rgba(var(--accent-rgb), 0.28) 0%,
rgba(var(--accent-light-rgb), 0.18) 100%);
}
.slider-group {
margin-top: 8px;
}
.slider-group label {
display: block;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
}
.dual-slider-container {
position: relative;
height: 40px;
margin: 10px 0;
}
.range-slider {
position: absolute;
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
pointer-events: none;
}
.range-slider::-webkit-slider-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
cursor: pointer;
pointer-events: all;
border: 2px solid #0a0a0a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 8px rgba(var(--accent-rgb), 0.2);
}
.range-slider::-webkit-slider-thumb:hover {
background: linear-gradient(135deg, rgb(var(--accent-light-rgb)), #22e968);
transform: scale(1.15);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4), 0 0 12px rgba(var(--accent-rgb), 0.3);
}
.range-slider::-moz-range-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
}
.range-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
cursor: pointer;
pointer-events: all;
border: 2px solid #0a0a0a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 8px rgba(var(--accent-rgb), 0.2);
}
.range-slider::-moz-range-thumb:hover {
background: linear-gradient(135deg, rgb(var(--accent-light-rgb)), #22e968);
transform: scale(1.15);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4), 0 0 12px rgba(var(--accent-rgb), 0.3);
}
.range-slider-track {
position: absolute;
top: 18px;
width: 100%;
height: 4px;
background: rgba(var(--accent-rgb), 0.25);
border-radius: 2px;
pointer-events: none;
}
.slider-values {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 500;
}
.slider-values span:first-child,
.slider-values span:last-child {
color: rgb(var(--accent-light-rgb));
font-weight: 700;
}
/* ===== END QUALITY PROFILE STYLES ===== */
.test-button {
background: rgba(var(--accent-rgb), 0.1);
color: rgb(var(--accent-light-rgb));
@ -4606,12 +4326,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: translateY(0) scale(0.97);
}
.settings-actions {
display: flex;
justify-content: center;
padding-top: 20px;
}
/* Additional Settings Components */
.path-input-group {
display: flex;
@ -22258,8 +21972,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
height: 520px;
width: 100%;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
/* Always reserve the scrollbar gutter so switching between a long log
(scrollbar present) and a short one (absent) never shifts the width. */
scrollbar-gutter: stable;
padding: 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
font-size: 12px;
@ -55701,20 +55420,123 @@ tr.tag-diff-same {
padding-bottom: 10vh;
}
/* Tab bar centered */
#settings-page .stg-tabbar {
/* Tab bar + save action sit on one control row. */
#settings-page .settings-nav-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 14px;
width: 100%;
max-width: 920px;
margin: 0 auto 28px;
padding: 0 2px;
}
#settings-page .stg-tabbar {
display: flex;
flex: 0 1 auto;
margin: 0;
width: fit-content;
max-width: 100%;
min-width: 0;
}
#settings-page .settings-nav-row .header-actions {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
align-items: center;
flex-wrap: nowrap;
width: auto;
margin: 0 0 0 auto;
margin-top: 0;
}
#settings-page .settings-nav-row .save-button {
padding: 8px 16px;
border-radius: 9px;
font-size: 0.84em;
font-weight: 600;
white-space: nowrap;
}
/* Columns become single centered column */
#settings-page .settings-columns {
flex-direction: column;
max-width: 760px;
max-width: 920px;
width: 100%;
gap: 0;
margin: 0 auto;
margin-bottom: 0;
}
/* The Logs tab uses the full available width the log terminal benefits from
the extra horizontal space (other tabs stay at the readable 920px). Matches
only while the logs group is the visible one (switchSettingsTab clears its
inline display when active, sets display:none otherwise). */
#settings-page .settings-columns:has(> .settings-group[data-stg="logs"]:not([style*="none"])) {
max-width: 100%;
}
#settings-page .settings-group[data-stg="logs"] {
width: 100%;
max-width: 100%;
min-width: 0;
}
#settings-page .settings-group[data-stg="logs"] .log-viewer-header,
#settings-page .settings-group[data-stg="logs"] .log-viewer-terminal,
#settings-page .settings-group[data-stg="logs"] .log-viewer-status {
width: 100%;
}
#settings-page .settings-group[data-stg="logs"] .log-viewer-controls,
#settings-page .settings-group[data-stg="logs"] .log-viewer-actions {
min-width: 0;
}
/* ── Settings local title — quiet, far-left, no dashboard banner ── */
#settings-page .dashboard-header.settings-header-compact {
flex-direction: row;
align-items: center;
width: 100%;
min-height: 0;
padding: 0;
margin: 0 0 12px;
position: relative;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: visible;
}
#settings-page .dashboard-header.settings-header-compact::after,
#settings-page .settings-header-compact .dashboard-header-sweep {
display: none;
}
#settings-page .settings-header-compact .header-text {
min-width: 0;
}
#settings-page .settings-header-compact .header-title {
font-size: 1.82em;
margin: 0;
line-height: 1.1;
color: rgba(255, 255, 255, 0.88);
}
#settings-page .settings-header-compact .page-header-icon {
width: 44px;
height: 44px;
opacity: 0.86;
}
/* Checkbox/label rows that carry an icon span the full width so the help
body wraps cleanly beneath them. */
#settings-page .form-group > .setting-row {
flex: 1 1 100%;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
}
#settings-page .settings-left-column,
#settings-page .settings-right-column,
@ -55779,12 +55601,14 @@ tr.tag-diff-same {
#settings-page .form-group {
display: flex;
align-items: center;
justify-content: space-between;
/* Row grammar: label grows to hold the left, control + group at the right.
(Was justify-content:space-between, which stranded selects in mid-row.) */
justify-content: flex-start;
flex-wrap: wrap;
gap: 8px 20px;
padding: 14px 8px !important;
gap: 8px 16px;
padding: 12px 8px !important;
margin: 0 !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.035);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
transition: background 0.2s;
}
@ -55797,9 +55621,17 @@ tr.tag-diff-same {
font-size: 0.9em;
color: rgba(255, 255, 255, 0.8);
font-weight: 400;
white-space: nowrap;
margin: 0;
flex-shrink: 0;
/* Grow to fill the left so the control aligns to a consistent right edge. */
flex: 1 1 auto;
min-width: 0;
}
/* The growing label pushes the control to a consistent right edge; the rides
immediately after the control as a pair so it can never shove it around. The
help callout (flex-basis:100%) wraps to its own line below. */
#settings-page .form-group > select,
#settings-page .form-group > .form-select {
flex: 0 0 auto;
}
#settings-page .form-group > input[type="text"],
@ -55818,11 +55650,27 @@ tr.tag-diff-same {
margin: 0;
transition: border-color 0.25s, background 0.25s, box-shadow 0.25s;
}
#settings-page .form-group > textarea {
padding: 9px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
color: rgba(255, 255, 255, 0.9);
font-size: 0.88em;
margin: 0;
resize: vertical;
transition: border-color 0.25s, background 0.25s, box-shadow 0.25s;
}
#settings-page .form-group > input:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
#settings-page .form-group > input:focus {
#settings-page .form-group > textarea:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
#settings-page .form-group > input:focus,
#settings-page .form-group > textarea:focus {
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(255, 255, 255, 0.06);
outline: none;
@ -56009,18 +55857,6 @@ tr.tag-diff-same {
flex-wrap: wrap;
}
/* ── Quality section ── */
#settings-page .quality-tier {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
margin-bottom: 10px;
transition: border-color 0.2s;
}
#settings-page .quality-tier:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
#settings-page .preset-button {
border-radius: 8px;
font-size: 0.84em;
@ -56049,6 +55885,15 @@ tr.tag-diff-same {
color: #fff;
}
#settings-page .checkbox-label:last-child { border-bottom: none; }
/* When a checkbox-label is the control of a .form-group row, the row separator
is the form-group's own border-bottom — drop the label's own border/box so
every row (checkbox or input/select) has ONE consistent full-width divider. */
#settings-page .form-group > .checkbox-label {
border-bottom: 0 !important;
border-radius: 0 !important;
padding: 4px 0 !important;
background: transparent !important;
}
/* ── Tag service groups ── */
#settings-page .tag-service-group {
@ -56070,30 +55915,6 @@ tr.tag-diff-same {
padding: 0 18px 12px;
}
/* ── Save button — centered, sticky feel ── */
#settings-page .settings-actions {
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 24px 0 16px;
}
#settings-page .settings-actions .save-button {
width: 100%;
padding: 12px;
border-radius: 10px;
font-size: 0.92em;
font-weight: 600;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
#settings-page .settings-actions .save-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(var(--accent-rgb, 29, 185, 84), 0.35);
}
#settings-page .settings-actions .save-button:active {
transform: scale(0.99);
box-shadow: 0 2px 8px rgba(var(--accent-rgb, 29, 185, 84), 0.2);
}
/* ── Path input groups (dir paths with Unlock buttons) ── */
#settings-page .path-input-group {
flex: 1;
@ -56157,6 +55978,191 @@ tr.tag-diff-same {
line-height: 1.5;
margin: 0;
}
/* Collapsible help bodies stay hidden until toggled wins over the
.setting-help-text/.settings-hint display rules that would otherwise show them. */
#settings-page .setting-help-body[hidden],
.setting-help-body[hidden] {
display: none !important;
}
/*
Expanded tile = ONE connected card. The header rounds only its top
and joins seamlessly to its body, which carries the side + bottom
frame; inner groups drop their own card frame so the expanded
settings clearly belong to the tile (no box-in-a-box, no detached feel).
*/
#settings-page .settings-section-header:not(.collapsed) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
/* Accent-tinted frame so the OPEN card clearly stands out from the grey
page instead of grey-on-grey. */
background: rgba(var(--accent-rgb), 0.07);
border-color: rgba(var(--accent-rgb), 0.30);
border-bottom: 1px solid rgba(var(--accent-rgb), 0.18);
}
#settings-page .settings-section-body:not(.collapsed) {
border: 1px solid rgba(var(--accent-rgb), 0.30);
border-top: none;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
/* Dark neutral body (accent stays on the border + header) so the lighter
inner sub-cards stand out clearly instead of washing out on a tint.
Symmetric padding so the inner sub-cards sit an equal distance from the
outer frame on every side. */
background: rgba(0, 0, 0, 0.22);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.28);
padding: 14px;
}
/* Inner groups inside an expanded tile: no own frame/shadow/accent bar
they're sub-sections of the one tile card, separated by their h3 labels. */
/* Sub-cards: each sub-section inside an OPEN tile sits in its own framed
card that stands out clearly on the accent-tinted tile background. Covers
inner settings-groups (Folder Paths, File Organization, Retry rows, ) and
the Security method cards. */
#settings-page .settings-section-body:not(.collapsed) > .settings-group,
#settings-page .settings-section-body:not(.collapsed) .security-subgroup,
#settings-page .settings-section-body:not(.collapsed) .settings-subcard,
#settings-page .settings-section-body:not(.collapsed) [id$="-settings-container"]:not(#hybrid-settings-container) {
background: rgba(255, 255, 255, 0.045) !important;
border: 1px solid rgba(255, 255, 255, 0.13) !important;
border-radius: 10px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25) !important;
padding: 6px 16px 12px !important;
margin: 0 0 14px !important;
}
#settings-page .settings-section-body:not(.collapsed) > .settings-group:last-child,
#settings-page .settings-section-body:not(.collapsed) .security-subgroup:last-child {
margin-bottom: 0 !important;
}
#settings-page .settings-section-body:not(.collapsed) .security-subgroup {
padding-top: 14px !important;
}
#settings-page .settings-section-body:not(.collapsed) > .settings-group::before { display: none; }
/* Sub-card title: clean header inside its frame (no accent bar / huge padding). */
#settings-page .settings-section-body:not(.collapsed) > .settings-group > h3 {
padding: 10px 0 10px;
border-left: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
/* The Security wrapper group is a transparent passthrough its .security-subgroup
children are the actual sub-cards (avoids a card-in-a-card). */
#settings-page .settings-section-body:not(.collapsed) > .settings-group.security-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 4px 14px 6px !important;
margin: 0 !important;
}
/* Mid-level framed containers inside a tile sub-card get flattened so there are
never 3 stacked frames (tile sub-card container). The innermost
interactive items (draggable hybrid rows, tag-embed accordions) keep theirs. */
#settings-page .settings-section-body:not(.collapsed) .api-service-frame,
#settings-page .settings-section-body:not(.collapsed) .post-processing-section,
#settings-page .settings-section-body:not(.collapsed) #hybrid-settings-container {
border: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* The Source Settings group is a transparent passthrough: the basic source/
stream/concurrency rows + the hybrid drag list sit flat in the tile, and each
per-source config pops open in ITS OWN frame directly inside the tile
(tile config card, never a triple frame). */
#settings-page .settings-section-body:not(.collapsed) > .settings-group.source-settings-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 4px 14px 6px !important;
margin: 0 !important;
}
/*
Collapsible info panel ( help) one consistent pattern.
The is a small circular badge flush at the end of its row; the
help body, when revealed, is an indented accent-bordered panel that
spans the full width below the control. Rows never reflow sideways.
*/
#settings-page .info-icon {
/* Single clean circle with a centred "i" (was the glyph, which carried
its own inner circle a double-circle that read as off-centre). */
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(var(--accent-rgb), 0.45);
background: transparent;
color: rgba(var(--accent-rgb), 0.9);
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
font-weight: 700;
font-size: 11px;
line-height: 1;
text-align: center;
margin-left: 6px;
flex: 0 0 auto;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
#settings-page .info-icon:hover {
background: rgba(var(--accent-rgb), 0.16);
border-color: rgba(var(--accent-rgb), 0.85);
color: rgba(var(--accent-rgb), 1);
}
#settings-page .info-icon.open {
background: rgba(var(--accent-rgb), 0.9);
border-color: rgba(var(--accent-rgb), 0.9);
color: #0b0f14;
}
/* The revealed panel indented, readable, clearly a callout (not a
detached muted line). Higher source-order than the .settings-hint /
.setting-help-text colour rules above, so it wins on equal specificity. */
#settings-page .setting-help-body {
display: block;
width: 100%;
flex-basis: 100%;
box-sizing: border-box;
margin: 8px 0 4px;
padding: 10px 12px;
background: rgba(var(--accent-rgb), 0.06);
border-left: 2px solid rgba(var(--accent-rgb), 0.5);
border-radius: 0 6px 6px 0;
font-size: 0.8em;
line-height: 1.55;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 0.01em;
}
#settings-page .setting-help-body strong { color: rgba(255, 255, 255, 0.82); font-weight: 600; }
#settings-page .setting-help-body em { color: rgba(var(--accent-rgb), 0.9); font-style: normal; }
#settings-page .setting-help-body code {
font-family: monospace;
background: rgba(255, 255, 255, 0.06);
padding: 1px 5px;
border-radius: 4px;
color: rgba(255, 255, 255, 0.75);
}
/* A checkbox-label inside a .setting-row must drop its row-divider border and
box padding otherwise it draws a partial underline only under the label
text and sits in a padded box, misaligned with the . The label fills the
row so the icon is pushed flush to the right edge. (Replaces the old
per-element inline style="border:0;padding:0;flex:1" hacks.) */
#settings-page .setting-row > .checkbox-label {
flex: 1 1 auto;
border-bottom: 0 !important;
border-radius: 0 !important;
padding: 4px 0 !important;
}
#settings-page .setting-row > .checkbox-label:hover { background: transparent; }
/* Icon always sits at the right edge of its row, vertically centred. */
#settings-page .setting-row > .info-icon {
margin-left: auto;
align-self: center;
}
/* Standalone help text (not inside a form-group) — add separator */
#settings-page .settings-group > .setting-help-text {
padding: 6px 0 14px;
@ -56176,6 +56182,9 @@ tr.tag-diff-same {
#settings-page #qobuz-settings-container,
#settings-page #hifi-download-settings-container,
#settings-page #deezer-download-settings-container,
#settings-page #amazon-download-settings-container,
#settings-page #lidarr-download-settings-container,
#settings-page #soundcloud-download-settings-container,
#settings-page #youtube-settings-container,
#settings-page #hybrid-settings-container {
background: rgba(255, 255, 255, 0.02);
@ -56190,18 +56199,15 @@ tr.tag-diff-same {
#settings-page #qobuz-settings-container:hover,
#settings-page #hifi-download-settings-container:hover,
#settings-page #deezer-download-settings-container:hover,
#settings-page #amazon-download-settings-container:hover,
#settings-page #lidarr-download-settings-container:hover,
#settings-page #soundcloud-download-settings-container:hover,
#settings-page #youtube-settings-container:hover,
#settings-page #hybrid-settings-container:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
}
/* ── Path inputs with lock buttons ── */
#settings-page .form-group .path-input-wrapper {
flex: 1;
max-width: 340px;
}
/* ── Hybrid source priority list (drag and drop) ── */
.hybrid-source-list {
display: flex;
@ -56279,6 +56285,36 @@ tr.tag-diff-same {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.hybrid-source-name[onclick]:hover {
color: rgb(var(--accent-light-rgb, 29, 185, 84));
}
/* ⚙ Configure button on each enabled source row */
.hybrid-source-config-btn {
flex-shrink: 0;
background: none;
border: none;
color: rgba(255, 255, 255, 0.3);
font-size: 0.95em;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
line-height: 1;
transition: all 0.15s;
}
.hybrid-source-config-btn:hover {
color: rgb(var(--accent-light-rgb, 29, 185, 84));
background: rgba(255, 255, 255, 0.06);
transform: rotate(45deg);
}
/* Row whose config panel is currently open */
.hybrid-source-item.config-open {
border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.45);
background: rgba(var(--accent-rgb, 29, 185, 84), 0.06);
}
.hybrid-source-item.config-open .hybrid-source-config-btn {
color: rgb(var(--accent-light-rgb, 29, 185, 84));
transform: rotate(45deg);
}
.hybrid-source-badge {
flex-shrink: 0;
font-size: 0.68em;
@ -56303,6 +56339,35 @@ tr.tag-diff-same {
min-width: 18px;
text-align: center;
}
/* Drag handle + drop-target highlight (mirrors the Quality ranked-target list). */
.hybrid-source-handle {
cursor: grab;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
line-height: 1;
padding: 0 2px;
user-select: none;
flex-shrink: 0;
}
.hybrid-source-handle:active { cursor: grabbing; }
.hybrid-source-item.drag-over {
border-color: rgba(var(--accent-rgb), 0.7) !important;
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.4);
}
/* Per-source live connection status dot (set by "Test all sources"). */
.hybrid-source-status {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.18);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.hybrid-source-status.hss-ok { background: #34d27b; box-shadow: 0 0 6px rgba(52, 210, 123, 0.6); }
.hybrid-source-status.hss-fail { background: #ff5f57; box-shadow: 0 0 6px rgba(255, 95, 87, 0.5); }
.hybrid-source-status.hss-na { background: rgba(255, 255, 255, 0.12); }
.hybrid-source-status.hss-testing { background: #f0b429; animation: hssPulse 0.9s ease-in-out infinite; }
@keyframes hssPulse { 0%,100% { opacity: 0.35; } 50% { opacity: 1; } }
.hybrid-source-toggle {
position: relative;
width: 36px;
@ -56549,11 +56614,20 @@ tr.tag-diff-same {
/* ── Responsive ── */
@media (max-width: 768px) {
#settings-page .settings-nav-row {
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
padding: 0 8px;
}
#settings-page .settings-nav-row .header-actions {
margin-left: auto;
}
/* Tab bar scrolls horizontally */
#settings-page .stg-tabbar {
width: 100%;
flex: 1 1 100%;
border-radius: 0;
margin-bottom: 16px;
justify-content: flex-start;
}
.stg-tab { padding: 8px 14px; font-size: 0.8em; }
@ -56583,9 +56657,6 @@ tr.tag-diff-same {
/* Accordion headers */
#settings-page .stg-service > .stg-service-header { padding: 12px 14px; }
#settings-page .stg-service > .stg-service-body { padding: 0 0 8px; }
/* Save button */
#settings-page .settings-actions { padding: 16px 8px; }
#settings-page .settings-actions .save-button { width: 100%; }
/* Section titles */
#settings-page .settings-group > h3 { padding: 24px 0 10px; }
}
@ -69095,6 +69166,17 @@ body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not
.reid-footer-actions { display: flex; gap: 10px; }
#reid-confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.4); }
/* Settings cleanup: source settings uses the same inner-card spacing as Retry Logic. */
#settings-page .settings-section-body:not(.collapsed) > .settings-group.source-settings-wrapper {
padding: 0 !important;
display: flex;
flex-direction: column;
gap: 14px;
}
#settings-page .settings-section-body:not(.collapsed) > .settings-group.source-settings-wrapper > .settings-subcard {
margin: 0 !important;
}
/* Firefox-only performance overrides (#935). Firefox re-rasterizes blur()/backdrop-filter on
every composite where Chrome caches them, so the frosted-glass shell costs real idle GPU on
Firefox only. @supports(-moz-appearance) matches Firefox and NOT Chrome this whole block is