Update settings page overhaul
This commit is contained in:
parent
60dee1b4d8
commit
0276aa8764
3 changed files with 1129 additions and 449 deletions
978
webui/index.html
978
webui/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue