Merge pull request #943 from nick2000713/ui/settings-page-cleanup
UI/settings page cleanup
This commit is contained in:
commit
6a388c43fc
6 changed files with 1384 additions and 812 deletions
|
|
@ -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
|
||||
|
|
|
|||
1031
webui/index.html
1031
webui/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue