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:
dev 2026-06-25 22:01:09 +02:00
parent ab05508acc
commit 2668980872
3 changed files with 191 additions and 49 deletions

View file

@ -5067,7 +5067,6 @@
<!-- Quality Profile Settings (global — applies to all download sources) -->
<div class="settings-group" id="quality-profile-section" data-stg="downloads">
<h3>🎵 Quality Profile</h3>
<!-- Presets -->
<div class="quality-presets">
@ -5086,17 +5085,38 @@
💾 Space Saver
</button>
</div>
<div class="help-text" style="margin-top:6px">
Edits you make below are saved <em>per preset</em> — switch away and back and
your changes are still there. Use
<a href="#" onclick="resetActiveQualityPreset(); return false;">Reset to defaults</a>
to restore the selected preset's factory settings.
<div class="quality-presets-footer">
<span class="help-text" style="padding:0;border:0;background:none;margin:0;flex:1;">
Edits below are saved <em>per preset</em> — switch away and back and your
changes are still there.
</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>
<!-- Ranked target priority list (v3) -->
<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">
<!-- rows injected by renderRankedTargets() -->
</div>
@ -5154,45 +5174,82 @@
<!-- Search strategy -->
<div class="form-group">
<label for="quality-search-mode" style="display:block;margin-bottom:4px;font-weight:600;">Search strategy</label>
<select id="quality-search-mode" style="width:100%;max-width:420px;">
<option value="priority">Source priority — fastest, stops at first good source</option>
<option value="best_quality">Best quality — search all sources, pick the highest</option>
<div class="setting-row" style="margin-bottom:4px;">
<label for="quality-search-mode" style="font-weight:600;">Search strategy</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>
<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>
<div class="help-text" style="margin-top:4px">
<strong>Source priority</strong> takes the first source in your chain that meets
a target — fast, fewer lookups. <strong>Best quality</strong> searches
<em>every</em> source for each track, pools the results, and downloads them
best→worst by actual audio quality (source order only breaks ties). Slower and
more API calls, but it won't settle for a passable Soulseek file when HiFi/Qobuz
has a higher-resolution version. Retry budgets are unchanged: a source that spends
its budget is dropped from the whole pool.
<div class="help-text setting-help-body" hidden>
<strong>Source priority</strong> goes through your sources in order and
downloads from the <em>first one</em> that has the track. Fastest, fewest
lookups.<br><br>
<strong>Best quality</strong> asks <em>every</em> source, then downloads the
highest-quality copy it found — even if a faster source also had a lower-quality
one. Slower and more API calls, but you never settle for a worse file when a
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>
<!-- Fallback Option -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="quality-fallback-enabled" checked>
Accept off-list quality when nothing in the list is available
</label>
<div class="help-text" style="margin-top:4px">
⚠️ This does <strong>not</strong> mean "walk down my list" — the list already
does that on its own. When <strong>on</strong>, a file that matches
<em>none</em> of your targets (e.g. a 16-bit FLAC or MP3 when you only listed
24-bit) is <strong>accepted anyway</strong> as a last resort. Turn it
<strong>off</strong> to strictly enforce your list — anything off-list is then
quarantined instead of completed.
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="quality-fallback-enabled" checked>
Accept off-list quality when nothing in the list is available
</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>
This does <strong>not</strong> mean "walk down my list" — the list already
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>
<!-- Require hard AcoustID verification — only shown when AcoustID is enabled -->
<div class="form-group" id="acoustid-require-verified-group" style="display:none;">
<label class="checkbox-label">
<input type="checkbox" id="acoustid-require-verified">
Only import AcoustID-verified tracks (reject "could not confirm")
</label>
<div class="help-text" style="margin-top:4px">
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="acoustid-require-verified">
Only import AcoustID-verified tracks (reject "could not confirm")
</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>
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
<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.
</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><!-- end Quality Profile body -->

View file

@ -2001,6 +2001,32 @@ function populateQualityProfileUI(profile) {
const searchModeSelect = document.getElementById('quality-search-mode');
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() {
@ -2211,6 +2237,7 @@ function collectQualityProfileFromUI() {
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
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,
};
}

View file

@ -3921,6 +3921,41 @@ body.helper-mode-active #dashboard-activity-feed:hover {
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 {
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);
}
/* 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 */
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
.ranked-targets-editor {
@ -4123,6 +4191,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
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 {
display: flex;
flex-direction: column;