feat(ui): collapsible ⓘ help, rank-based toggle, tidier quality-profile settings
Quality-profile settings UI cleanup:
- Add the "Rank-based download order" toggle (priority mode). It's hidden when
Best quality is active, since that mode always ranks by quality.
- Plain-language search-strategy options ("fast" / "thorough"); load + save the
new rank_candidates_by_quality flag.
- Move the long help texts behind a dim ⓘ icon that sits on the (fixed) label
row and toggles a collapsible body below — the trigger no longer moves on
open. Applied to: search strategy, rank-based order, off-list fallback,
AcoustID-verified, and the "How it works" ranked-targets explainer.
toggleSettingHelp walks to the next .setting-help-body sibling so it works
regardless of wrapper or an in-between control.
- Fix the "Search strategy" label: zero the flex-row margin so it aligns with
the ⓘ, and bump it to 12px/brighter so it doesn't read as dim/undersized.
- Remove the duplicate "🎵 Quality Profile" heading inside the tile body.
- Replace the inline "Reset to defaults" link with a proper ↺ button.
- Restore the gap between the "Quality priority" label and the target list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ab05508acc
commit
2668980872
3 changed files with 191 additions and 49 deletions
141
webui/index.html
141
webui/index.html
|
|
@ -5067,7 +5067,6 @@
|
||||||
|
|
||||||
<!-- Quality Profile Settings (global — applies to all download sources) -->
|
<!-- Quality Profile Settings (global — applies to all download sources) -->
|
||||||
<div class="settings-group" id="quality-profile-section" data-stg="downloads">
|
<div class="settings-group" id="quality-profile-section" data-stg="downloads">
|
||||||
<h3>🎵 Quality Profile</h3>
|
|
||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<div class="quality-presets">
|
<div class="quality-presets">
|
||||||
|
|
@ -5086,17 +5085,38 @@
|
||||||
💾 Space Saver
|
💾 Space Saver
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="help-text" style="margin-top:6px">
|
<div class="quality-presets-footer">
|
||||||
Edits you make below are saved <em>per preset</em> — switch away and back and
|
<span class="help-text" style="padding:0;border:0;background:none;margin:0;flex:1;">
|
||||||
your changes are still there. Use
|
Edits below are saved <em>per preset</em> — switch away and back and your
|
||||||
<a href="#" onclick="resetActiveQualityPreset(); return false;">Reset to defaults</a>
|
changes are still there.
|
||||||
to restore the selected preset's factory settings.
|
</span>
|
||||||
|
<button type="button" class="preset-reset-btn" onclick="resetActiveQualityPreset()"
|
||||||
|
title="Restore the selected preset's factory settings">
|
||||||
|
<span class="preset-reset-icon">↺</span> Reset to defaults
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ranked target priority list (v3) -->
|
<!-- Ranked target priority list (v3) -->
|
||||||
<div class="ranked-targets-editor">
|
<div class="ranked-targets-editor">
|
||||||
<label class="ranked-targets-label">Quality priority (drag to reorder — 1st = most preferred):</label>
|
<div class="setting-row">
|
||||||
|
<label class="ranked-targets-label" style="margin:0;">Quality priority (drag to reorder — 1st = most preferred):</label>
|
||||||
|
<span class="info-icon" role="button" tabindex="0" title="What's this?"
|
||||||
|
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-text setting-help-body" hidden>
|
||||||
|
<strong>How it works:</strong> Each download source is checked against this list
|
||||||
|
top-down. The first target a source can satisfy wins; a source that meets no target
|
||||||
|
is skipped for the next one (source priority still decides between sources that can).
|
||||||
|
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
|
||||||
|
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
|
||||||
|
download the real file is verified against the same list. With fallback off, a track
|
||||||
|
is left missing rather than accepting a quality below every target.
|
||||||
|
<br><br>
|
||||||
|
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
|
||||||
|
bypasses this gate — if off-list files keep slipping through with fallback off, check
|
||||||
|
that setting too.
|
||||||
|
</div>
|
||||||
<div id="ranked-targets-list" class="ranked-targets-list">
|
<div id="ranked-targets-list" class="ranked-targets-list">
|
||||||
<!-- rows injected by renderRankedTargets() -->
|
<!-- rows injected by renderRankedTargets() -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5154,45 +5174,82 @@
|
||||||
|
|
||||||
<!-- Search strategy -->
|
<!-- Search strategy -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="quality-search-mode" style="display:block;margin-bottom:4px;font-weight:600;">Search strategy</label>
|
<div class="setting-row" style="margin-bottom:4px;">
|
||||||
<select id="quality-search-mode" style="width:100%;max-width:420px;">
|
<label for="quality-search-mode" style="font-weight:600;">Search strategy</label>
|
||||||
<option value="priority">Source priority — fastest, stops at first good source</option>
|
<span class="info-icon" role="button" tabindex="0" title="What's this?"
|
||||||
<option value="best_quality">Best quality — search all sources, pick the highest</option>
|
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
|
||||||
|
</div>
|
||||||
|
<select id="quality-search-mode" style="width:100%;max-width:420px;" onchange="onSearchModeChange()">
|
||||||
|
<option value="priority">Source priority — fast (use the first source that has it)</option>
|
||||||
|
<option value="best_quality">Best quality — thorough (check every source, take the best)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="help-text" style="margin-top:4px">
|
<div class="help-text setting-help-body" hidden>
|
||||||
<strong>Source priority</strong> takes the first source in your chain that meets
|
<strong>Source priority</strong> goes through your sources in order and
|
||||||
a target — fast, fewer lookups. <strong>Best quality</strong> searches
|
downloads from the <em>first one</em> that has the track. Fastest, fewest
|
||||||
<em>every</em> source for each track, pools the results, and downloads them
|
lookups.<br><br>
|
||||||
best→worst by actual audio quality (source order only breaks ties). Slower and
|
<strong>Best quality</strong> asks <em>every</em> source, then downloads the
|
||||||
more API calls, but it won't settle for a passable Soulseek file when HiFi/Qobuz
|
highest-quality copy it found — even if a faster source also had a lower-quality
|
||||||
has a higher-resolution version. Retry budgets are unchanged: a source that spends
|
one. Slower and more API calls, but you never settle for a worse file when a
|
||||||
its budget is dropped from the whole pool.
|
better one exists.<br><br>
|
||||||
|
Either way, the per-source retry limits stay the same.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rank candidates by quality (priority mode only) -->
|
||||||
|
<div class="form-group" id="quality-rank-candidates-group">
|
||||||
|
<div class="setting-row">
|
||||||
|
<label class="checkbox-label" style="margin:0;">
|
||||||
|
<input type="checkbox" id="quality-rank-candidates">
|
||||||
|
Rank-based download order
|
||||||
|
</label>
|
||||||
|
<span class="info-icon" role="button" tabindex="0" title="What's this?"
|
||||||
|
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-text setting-help-body" hidden>
|
||||||
|
A single source often returns several copies of the same track.<br><br>
|
||||||
|
<strong>Off</strong> (default): the most likely-correct / fastest copy is
|
||||||
|
downloaded first — the original behaviour.<br><br>
|
||||||
|
<strong>On</strong>: the highest-quality copy (per your target list above) is
|
||||||
|
downloaded first instead; correctness and speed only break ties.<br><br>
|
||||||
|
Nothing is skipped and the source order doesn't change, so a track can't go
|
||||||
|
missing — it just prefers the better copy. (This is always how
|
||||||
|
<strong>Best quality</strong> works, which is why this option only appears for
|
||||||
|
<strong>Source priority</strong>.)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fallback Option -->
|
<!-- Fallback Option -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<div class="setting-row">
|
||||||
<input type="checkbox" id="quality-fallback-enabled" checked>
|
<label class="checkbox-label" style="margin:0;">
|
||||||
Accept off-list quality when nothing in the list is available
|
<input type="checkbox" id="quality-fallback-enabled" checked>
|
||||||
</label>
|
Accept off-list quality when nothing in the list is available
|
||||||
<div class="help-text" style="margin-top:4px">
|
</label>
|
||||||
⚠️ This does <strong>not</strong> mean "walk down my list" — the list already
|
<span class="info-icon" role="button" tabindex="0" title="What's this?"
|
||||||
does that on its own. When <strong>on</strong>, a file that matches
|
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
|
||||||
<em>none</em> of your targets (e.g. a 16-bit FLAC or MP3 when you only listed
|
</div>
|
||||||
24-bit) is <strong>accepted anyway</strong> as a last resort. Turn it
|
<div class="help-text setting-help-body" hidden>
|
||||||
<strong>off</strong> to strictly enforce your list — anything off-list is then
|
This does <strong>not</strong> mean "walk down my list" — the list already
|
||||||
quarantined instead of completed.
|
does that on its own.<br><br>
|
||||||
|
When <strong>on</strong>, a file that matches <em>none</em> of your targets
|
||||||
|
(e.g. a 16-bit FLAC or an MP3 when you only listed 24-bit) is
|
||||||
|
<strong>accepted anyway</strong> as a last resort.<br><br>
|
||||||
|
Turn it <strong>off</strong> to strictly enforce your list — anything off-list
|
||||||
|
is then quarantined instead of completed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Require hard AcoustID verification — only shown when AcoustID is enabled -->
|
<!-- Require hard AcoustID verification — only shown when AcoustID is enabled -->
|
||||||
<div class="form-group" id="acoustid-require-verified-group" style="display:none;">
|
<div class="form-group" id="acoustid-require-verified-group" style="display:none;">
|
||||||
<label class="checkbox-label">
|
<div class="setting-row">
|
||||||
<input type="checkbox" id="acoustid-require-verified">
|
<label class="checkbox-label" style="margin:0;">
|
||||||
Only import AcoustID-verified tracks (reject "could not confirm")
|
<input type="checkbox" id="acoustid-require-verified">
|
||||||
</label>
|
Only import AcoustID-verified tracks (reject "could not confirm")
|
||||||
<div class="help-text" style="margin-top:4px">
|
</label>
|
||||||
|
<span class="info-icon" role="button" tabindex="0" title="What's this?"
|
||||||
|
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-text setting-help-body" hidden>
|
||||||
When <strong>on</strong>, a track that AcoustID runs but <em>cannot confirm</em>
|
When <strong>on</strong>, a track that AcoustID runs but <em>cannot confirm</em>
|
||||||
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
|
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
|
||||||
<strong>quarantined</strong> instead of imported. Downloads then try the next
|
<strong>quarantined</strong> instead of imported. Downloads then try the next
|
||||||
|
|
@ -5210,20 +5267,6 @@
|
||||||
outage) always import, so an AcoustID outage never stalls you. Requires AcoustID enabled.
|
outage) always import, so an AcoustID outage never stalls you. Requires AcoustID enabled.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-text">
|
|
||||||
<strong>How it works:</strong> Each download source is checked against this list
|
|
||||||
top-down. The first target a source can satisfy wins; a source that meets no target
|
|
||||||
is skipped for the next one (source priority still decides between sources that can).
|
|
||||||
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
|
|
||||||
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
|
|
||||||
download the real file is verified against the same list. With fallback off, a track
|
|
||||||
is left missing rather than accepting a quality below every target.
|
|
||||||
<br><br>
|
|
||||||
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
|
|
||||||
bypasses this gate — if off-list files keep slipping through with fallback off, check
|
|
||||||
that setting too.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- end Quality Profile body -->
|
</div><!-- end Quality Profile body -->
|
||||||
|
|
|
||||||
|
|
@ -2001,6 +2001,32 @@ function populateQualityProfileUI(profile) {
|
||||||
|
|
||||||
const searchModeSelect = document.getElementById('quality-search-mode');
|
const searchModeSelect = document.getElementById('quality-search-mode');
|
||||||
if (searchModeSelect) searchModeSelect.value = profile.search_mode === 'best_quality' ? 'best_quality' : 'priority';
|
if (searchModeSelect) searchModeSelect.value = profile.search_mode === 'best_quality' ? 'best_quality' : 'priority';
|
||||||
|
|
||||||
|
const rankCandidatesCheckbox = document.getElementById('quality-rank-candidates');
|
||||||
|
if (rankCandidatesCheckbox) rankCandidatesCheckbox.checked = profile.rank_candidates_by_quality === true;
|
||||||
|
|
||||||
|
onSearchModeChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the "rank-based download order" toggle when Best quality is active —
|
||||||
|
// that mode always ranks by quality, so the toggle would be meaningless there.
|
||||||
|
function onSearchModeChange() {
|
||||||
|
const mode = document.getElementById('quality-search-mode')?.value;
|
||||||
|
const group = document.getElementById('quality-rank-candidates-group');
|
||||||
|
if (group) group.style.display = mode === 'best_quality' ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the collapsible help text below a setting's ⓘ icon. Walks forward from
|
||||||
|
// the icon's row to the next .setting-help-body sibling, so it works whether the
|
||||||
|
// body is the immediate next element or sits after a control (e.g. a <select>),
|
||||||
|
// and regardless of any wrapping container.
|
||||||
|
function toggleSettingHelp(iconEl) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRankedTargets() {
|
function renderRankedTargets() {
|
||||||
|
|
@ -2211,6 +2237,7 @@ function collectQualityProfileFromUI() {
|
||||||
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
|
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
|
||||||
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
|
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
|
||||||
search_mode: document.getElementById('quality-search-mode')?.value === 'best_quality' ? 'best_quality' : 'priority',
|
search_mode: document.getElementById('quality-search-mode')?.value === 'best_quality' ? 'best_quality' : 'priority',
|
||||||
|
rank_candidates_by_quality: document.getElementById('quality-rank-candidates')?.checked ?? false,
|
||||||
ranked_targets,
|
ranked_targets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3921,6 +3921,41 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Setting label + inline ⓘ info icon. The icon sits on the (fixed) label row;
|
||||||
|
clicking it only toggles the help text below, so the trigger never moves. */
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* Kill the default .form-group label margin-bottom so the label centers on the
|
||||||
|
icon row instead of sitting high. */
|
||||||
|
.setting-row > label { margin: 0; }
|
||||||
|
/* Field-heading labels (e.g. "Search strategy") sit next to a 12px control and
|
||||||
|
the brighter checkbox labels — match that weight/brightness so they don't look
|
||||||
|
dim or undersized. */
|
||||||
|
.setting-row > label:not(.checkbox-label) {
|
||||||
|
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 */
|
||||||
.supported-formats {
|
.supported-formats {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
@ -4109,6 +4144,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.15);
|
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Presets footer: explanation on the left, a tidy Reset button on the right */
|
||||||
|
.quality-presets-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.preset-reset-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.preset-reset-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.35);
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
.preset-reset-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Quality tier cards - upgraded inner cards */
|
/* Quality tier cards - upgraded inner cards */
|
||||||
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
|
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
|
||||||
.ranked-targets-editor {
|
.ranked-targets-editor {
|
||||||
|
|
@ -4123,6 +4191,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The label now sits in a .setting-row (alongside the ⓘ), whose label margin is
|
||||||
|
zeroed — restore a healthy gap before the draggable target list below. */
|
||||||
|
.ranked-targets-editor > .setting-row { margin-bottom: 10px; }
|
||||||
|
|
||||||
.ranked-targets-list {
|
.ranked-targets-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue