Update settings page overhaul

This commit is contained in:
dev 2026-06-28 22:52:09 +02:00
parent 60dee1b4d8
commit 0276aa8764
3 changed files with 1129 additions and 449 deletions

File diff suppressed because it is too large Load diff

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 import 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

@ -3269,10 +3269,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,16 +3396,16 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.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;
}
@ -21800,8 +21804,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;
@ -55251,12 +55260,63 @@ tr.tag-diff-same {
/* 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%;
}
/* ── Compact settings header — single row, no wasted vertical space ── */
#settings-page .dashboard-header.settings-header-compact {
flex-direction: row;
align-items: center;
padding: 12px 24px;
margin-bottom: 16px;
position: relative;
}
/* Centre the Save button in the header regardless of the title width. */
#settings-page .settings-header-compact .header-actions {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
#settings-page .settings-header-compact .header-title {
font-size: 1.15em;
margin: 0;
}
#settings-page .settings-header-compact .page-header-icon {
width: 26px;
height: 26px;
}
#settings-page .settings-header-compact .header-actions {
margin-left: auto;
}
#settings-page .settings-header-compact .header-actions .save-button {
padding: 9px 22px;
border-radius: 10px;
font-size: 0.9em;
font-weight: 600;
}
/* 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,
@ -55321,12 +55381,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;
}
@ -55339,9 +55401,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"],
@ -55579,6 +55649,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 {
@ -55663,6 +55742,188 @@ 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) > .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;
@ -55682,6 +55943,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);
@ -55696,6 +55960,9 @@ 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);
@ -55779,6 +56046,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;
@ -55803,6 +56100,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;