feat(quality): v3 ranked-targets UI editor (drag-to-reorder)
Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth buttons) with a draggable ordered target list. Each row shows its rank + label with move/delete; an add form picks format and, for lossless, bit depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the existing /api/quality-profile. Presets + fallback toggle retained; help text and tooltip rewritten for the new top-down source-gating model. Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
71f7c4a5e1
commit
e0c55342bc
4 changed files with 310 additions and 332 deletions
183
webui/index.html
183
webui/index.html
|
|
@ -5087,144 +5087,41 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FLAC Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-flac-enabled" checked
|
||||
onchange="toggleQuality('flac')">
|
||||
<span class="quality-tier-name">FLAC (Lossless)</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-flac">Priority: 1</span>
|
||||
<!-- 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 id="ranked-targets-list" class="ranked-targets-list">
|
||||
<!-- rows injected by renderRankedTargets() -->
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-flac">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min" id="flac-min"
|
||||
min="0" max="10000" value="500" step="100"
|
||||
oninput="updateQualityRange('flac')">
|
||||
<input type="range" class="range-slider range-slider-max" id="flac-max"
|
||||
min="0" max="10000" value="10000" step="100"
|
||||
oninput="updateQualityRange('flac')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="flac-min-value">500 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="flac-max-value">10000 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flac-bit-depth-selector" id="flac-bit-depth-selector">
|
||||
<label>Bit Depth:</label>
|
||||
<div class="bit-depth-buttons">
|
||||
<button class="bit-depth-btn active" data-value="any" onclick="setFlacBitDepth('any')">Any</button>
|
||||
<button class="bit-depth-btn" data-value="16" onclick="setFlacBitDepth('16')">16-bit</button>
|
||||
<button class="bit-depth-btn" data-value="24" onclick="setFlacBitDepth('24')">24-bit</button>
|
||||
</div>
|
||||
<div class="flac-fallback-toggle" id="flac-fallback-toggle" style="display: none; margin-top: 6px;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.8em; color: #ccc;">
|
||||
<input type="checkbox" id="flac-bit-depth-fallback" checked onchange="setFlacBitDepthFallback(this.checked)">
|
||||
Accept other bit depths as fallback
|
||||
|
||||
<!-- Add a new target -->
|
||||
<div class="ranked-target-add">
|
||||
<select id="rt-add-format" onchange="onRtAddFormatChange()">
|
||||
<option value="flac">FLAC (lossless)</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="aac">AAC</option>
|
||||
</select>
|
||||
<span class="rt-lossless-fields">
|
||||
<select id="rt-add-bitdepth" title="Minimum bit depth">
|
||||
<option value="">Any bit depth</option>
|
||||
<option value="16">16-bit</option>
|
||||
<option value="24">24-bit</option>
|
||||
</select>
|
||||
<select id="rt-add-samplerate" title="Minimum sample rate">
|
||||
<option value="">Any sample rate</option>
|
||||
<option value="44100">≥ 44.1 kHz</option>
|
||||
<option value="48000">≥ 48 kHz</option>
|
||||
<option value="96000">≥ 96 kHz</option>
|
||||
<option value="192000">≥ 192 kHz</option>
|
||||
</select>
|
||||
</span>
|
||||
<span class="rt-lossy-fields" style="display:none;">
|
||||
<label class="rt-inline-label">≥
|
||||
<input type="number" id="rt-add-bitrate" min="0" max="2000" step="1" value="320" style="width:70px;"
|
||||
autocomplete="off" data-bwignore="" data-1p-ignore="" data-lpignore="true"> kbps
|
||||
</label>
|
||||
<div style="color: #888; font-size: 0.75em; margin-top: 2px;">
|
||||
When enabled, accepts any FLAC rather than rejecting on bit depth mismatch
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 320 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_320-enabled" checked
|
||||
onchange="toggleQuality('mp3_320')">
|
||||
<span class="quality-tier-name">MP3 320 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_320">Priority: 2</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-mp3_320">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min"
|
||||
id="mp3_320-min" min="0" max="500" value="280" step="10"
|
||||
oninput="updateQualityRange('mp3_320')">
|
||||
<input type="range" class="range-slider range-slider-max"
|
||||
id="mp3_320-max" min="0" max="500" value="500" step="10"
|
||||
oninput="updateQualityRange('mp3_320')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="mp3_320-min-value">280 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="mp3_320-max-value">500 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 256 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_256-enabled" checked
|
||||
onchange="toggleQuality('mp3_256')">
|
||||
<span class="quality-tier-name">MP3 256 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_256">Priority: 3</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-mp3_256">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min"
|
||||
id="mp3_256-min" min="0" max="400" value="200" step="10"
|
||||
oninput="updateQualityRange('mp3_256')">
|
||||
<input type="range" class="range-slider range-slider-max"
|
||||
id="mp3_256-max" min="0" max="400" value="400" step="10"
|
||||
oninput="updateQualityRange('mp3_256')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="mp3_256-min-value">200 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="mp3_256-max-value">400 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 192 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-mp3_192-enabled"
|
||||
onchange="toggleQuality('mp3_192')">
|
||||
<span class="quality-tier-name">MP3 192 kbps</span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-mp3_192">Priority: 4</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders disabled" id="sliders-mp3_192">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min"
|
||||
id="mp3_192-min" min="0" max="300" value="150" step="10"
|
||||
oninput="updateQualityRange('mp3_192')">
|
||||
<input type="range" class="range-slider range-slider-max"
|
||||
id="mp3_192-max" min="0" max="300" value="300" step="10"
|
||||
oninput="updateQualityRange('mp3_192')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="mp3_192-min-value">150 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="mp3_192-max-value">300 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<button type="button" class="rt-add-btn" onclick="addRankedTarget()">+ Add target</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -5232,16 +5129,18 @@
|
|||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-fallback-enabled" checked>
|
||||
Allow fallback to any quality if preferred qualities unavailable
|
||||
Allow fallback to any quality if no target is available
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="help-text">
|
||||
<strong>How it works:</strong> Downloads try each enabled quality in priority order
|
||||
(1 = highest).
|
||||
MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a
|
||||
re-encoded MP3). MAX bitrate limits hi-res files if you want to save space.
|
||||
When track duration is unavailable, a generous file-size safety net is used instead.
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1872,9 +1872,9 @@ const HELPER_CONTENT = {
|
|||
title: 'Quality Preset',
|
||||
description: 'One-click quality configuration. Presets set all format enables, priorities, and bitrate ranges at once.',
|
||||
},
|
||||
'.bit-depth-btn': {
|
||||
title: 'FLAC Bit Depth',
|
||||
description: 'Prefer 16-bit (CD quality, smaller), 24-bit (hi-res, larger), or Any. When a specific depth is chosen, the fallback toggle controls whether other depths are accepted.',
|
||||
'.ranked-targets-editor': {
|
||||
title: 'Quality Priority List',
|
||||
description: 'Ordered list of acceptable qualities (1st = most preferred). Each source is checked top-down; the first target it can satisfy wins. Lossless matches on bit depth + sample rate; MP3/AAC use a minimum bitrate (≥) so VBR/mono files aren\'t falsely rejected. Drag to reorder.',
|
||||
docsId: 'set-quality'
|
||||
},
|
||||
'#quality-fallback-enabled': {
|
||||
|
|
|
|||
|
|
@ -1904,175 +1904,132 @@ async function loadQualityProfile() {
|
|||
}
|
||||
}
|
||||
|
||||
// v3: the working copy of the ordered target list. Mirrors the DOM rows
|
||||
// and is the single source of truth that collectQualityProfileFromUI reads.
|
||||
let currentRankedTargets = [];
|
||||
|
||||
function rtLabel(t) {
|
||||
const fmt = (t.format || 'any').toUpperCase();
|
||||
if (t.format === 'flac' || t.format === 'wav') {
|
||||
const bd = t.bit_depth ? `${t.bit_depth}-bit` : '';
|
||||
const sr = t.min_sample_rate ? `≥${t.min_sample_rate / 1000}kHz` : '';
|
||||
const detail = [bd, sr].filter(Boolean).join('/');
|
||||
return detail ? `${fmt} ${detail}` : fmt;
|
||||
}
|
||||
return t.min_bitrate ? `${fmt} ≥${t.min_bitrate}kbps` : fmt;
|
||||
}
|
||||
|
||||
function populateQualityProfileUI(profile) {
|
||||
// Update preset buttons
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active'));
|
||||
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
|
||||
if (activePresetBtn) {
|
||||
activePresetBtn.classList.add('active');
|
||||
}
|
||||
if (activePresetBtn) activePresetBtn.classList.add('active');
|
||||
|
||||
// Populate each quality tier
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
qualities.forEach(quality => {
|
||||
const config = profile.qualities[quality];
|
||||
if (config) {
|
||||
// Set enabled checkbox
|
||||
const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
|
||||
if (enabledCheckbox) {
|
||||
enabledCheckbox.checked = config.enabled;
|
||||
}
|
||||
// The API migrates v2 → v3, so ranked_targets is always present.
|
||||
currentRankedTargets = Array.isArray(profile.ranked_targets)
|
||||
? profile.ranked_targets.map(t => ({ ...t }))
|
||||
: [];
|
||||
renderRankedTargets();
|
||||
|
||||
// Set min/max sliders
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
if (minSlider && maxSlider) {
|
||||
minSlider.value = config.min_kbps;
|
||||
maxSlider.value = config.max_kbps;
|
||||
updateQualityRange(quality);
|
||||
}
|
||||
|
||||
// Set priority display
|
||||
const prioritySpan = document.getElementById(`priority-${quality}`);
|
||||
if (prioritySpan) {
|
||||
prioritySpan.textContent = `Priority: ${config.priority}`;
|
||||
}
|
||||
|
||||
// Toggle sliders visibility
|
||||
const sliders = document.getElementById(`sliders-${quality}`);
|
||||
if (sliders) {
|
||||
if (config.enabled) {
|
||||
sliders.classList.remove('disabled');
|
||||
} else {
|
||||
sliders.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// FLAC-specific: restore bit depth selector and fallback toggle
|
||||
if (quality === 'flac') {
|
||||
const bitDepthValue = config.bit_depth || 'any';
|
||||
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue);
|
||||
});
|
||||
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
|
||||
if (bitDepthSelector) {
|
||||
if (config.enabled) {
|
||||
bitDepthSelector.classList.remove('disabled');
|
||||
} else {
|
||||
bitDepthSelector.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
// Show/hide and restore fallback toggle
|
||||
const fallbackToggle = document.getElementById('flac-fallback-toggle');
|
||||
if (fallbackToggle) {
|
||||
fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block';
|
||||
}
|
||||
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
|
||||
if (fallbackCb) {
|
||||
fallbackCb.checked = config.bit_depth_fallback !== false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set fallback checkbox
|
||||
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
|
||||
if (fallbackCheckbox) {
|
||||
fallbackCheckbox.checked = profile.fallback_enabled;
|
||||
}
|
||||
if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false;
|
||||
}
|
||||
|
||||
function updateQualityRange(quality) {
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
const minValue = document.getElementById(`${quality}-min-value`);
|
||||
const maxValue = document.getElementById(`${quality}-max-value`);
|
||||
function renderRankedTargets() {
|
||||
const list = document.getElementById('ranked-targets-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!minSlider || !maxSlider || !minValue || !maxValue) return;
|
||||
|
||||
let min = parseInt(minSlider.value);
|
||||
let max = parseInt(maxSlider.value);
|
||||
|
||||
// Ensure min doesn't exceed max
|
||||
if (min > max) {
|
||||
min = max;
|
||||
minSlider.value = min;
|
||||
if (currentRankedTargets.length === 0) {
|
||||
list.innerHTML = '<div class="ranked-targets-empty">No targets yet — add one below. '
|
||||
+ 'With fallback off this would reject every download.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure max doesn't go below min
|
||||
if (max < min) {
|
||||
max = min;
|
||||
maxSlider.value = max;
|
||||
}
|
||||
|
||||
minValue.textContent = `${min} kbps`;
|
||||
maxValue.textContent = `${max} kbps`;
|
||||
}
|
||||
|
||||
function toggleQuality(quality) {
|
||||
const checkbox = document.getElementById(`quality-${quality}-enabled`);
|
||||
const sliders = document.getElementById(`sliders-${quality}`);
|
||||
|
||||
if (checkbox && sliders) {
|
||||
if (checkbox.checked) {
|
||||
sliders.classList.remove('disabled');
|
||||
} else {
|
||||
sliders.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Also toggle FLAC bit depth selector
|
||||
if (quality === 'flac') {
|
||||
const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
|
||||
if (bitDepthSelector && checkbox) {
|
||||
if (checkbox.checked) {
|
||||
bitDepthSelector.classList.remove('disabled');
|
||||
} else {
|
||||
bitDepthSelector.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark preset as custom when manually changing
|
||||
if (currentQualityProfile) {
|
||||
currentQualityProfile.preset = 'custom';
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
currentRankedTargets.forEach((t, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ranked-target-row';
|
||||
row.draggable = true;
|
||||
row.dataset.index = String(i);
|
||||
row.innerHTML = `
|
||||
<span class="rt-handle" title="Drag to reorder">⠿</span>
|
||||
<span class="rt-rank">${i + 1}</span>
|
||||
<span class="rt-label">${rtLabel(t)}</span>
|
||||
<span class="rt-spacer"></span>
|
||||
<button type="button" class="rt-move" title="Move up" onclick="moveRankedTarget(${i}, -1)">▲</button>
|
||||
<button type="button" class="rt-move" title="Move down" onclick="moveRankedTarget(${i}, 1)">▼</button>
|
||||
<button type="button" class="rt-del" title="Remove" onclick="deleteRankedTarget(${i})">🗑</button>
|
||||
`;
|
||||
row.addEventListener('dragstart', e => {
|
||||
e.dataTransfer.setData('text/plain', String(i));
|
||||
row.classList.add('rt-dragging');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setFlacBitDepth(value) {
|
||||
document.querySelectorAll('.bit-depth-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-value') === value);
|
||||
row.addEventListener('dragend', () => row.classList.remove('rt-dragging'));
|
||||
row.addEventListener('dragover', e => { e.preventDefault(); row.classList.add('rt-dragover'); });
|
||||
row.addEventListener('dragleave', () => row.classList.remove('rt-dragover'));
|
||||
row.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
row.classList.remove('rt-dragover');
|
||||
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (!Number.isNaN(from) && from !== i) reorderRankedTarget(from, i);
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide fallback toggle — only relevant when a specific bit depth is selected
|
||||
const fallbackToggle = document.getElementById('flac-fallback-toggle');
|
||||
if (fallbackToggle) {
|
||||
fallbackToggle.style.display = value === 'any' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Mark preset as custom when manually changing
|
||||
if (currentQualityProfile) {
|
||||
currentQualityProfile.preset = 'custom';
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
}
|
||||
function markQualityProfileCustom() {
|
||||
if (currentQualityProfile) currentQualityProfile.preset = 'custom';
|
||||
document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active'));
|
||||
}
|
||||
|
||||
function reorderRankedTarget(from, to) {
|
||||
const [moved] = currentRankedTargets.splice(from, 1);
|
||||
currentRankedTargets.splice(to, 0, moved);
|
||||
markQualityProfileCustom();
|
||||
renderRankedTargets();
|
||||
debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
function setFlacBitDepthFallback(enabled) {
|
||||
if (currentQualityProfile) {
|
||||
currentQualityProfile.preset = 'custom';
|
||||
document.querySelectorAll('.preset-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
function moveRankedTarget(i, dir) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= currentRankedTargets.length) return;
|
||||
[currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]];
|
||||
markQualityProfileCustom();
|
||||
renderRankedTargets();
|
||||
debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
function deleteRankedTarget(i) {
|
||||
currentRankedTargets.splice(i, 1);
|
||||
markQualityProfileCustom();
|
||||
renderRankedTargets();
|
||||
debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
function onRtAddFormatChange() {
|
||||
const lossless = document.getElementById('rt-add-format')?.value === 'flac';
|
||||
const llFields = document.querySelector('.rt-lossless-fields');
|
||||
const lyFields = document.querySelector('.rt-lossy-fields');
|
||||
if (llFields) llFields.style.display = lossless ? '' : 'none';
|
||||
if (lyFields) lyFields.style.display = lossless ? 'none' : '';
|
||||
}
|
||||
|
||||
function addRankedTarget() {
|
||||
const fmt = document.getElementById('rt-add-format')?.value || 'flac';
|
||||
const t = { format: fmt };
|
||||
if (fmt === 'flac') {
|
||||
const bd = document.getElementById('rt-add-bitdepth')?.value;
|
||||
const sr = document.getElementById('rt-add-samplerate')?.value;
|
||||
if (bd) t.bit_depth = parseInt(bd, 10);
|
||||
if (sr) t.min_sample_rate = parseInt(sr, 10);
|
||||
} else {
|
||||
const br = document.getElementById('rt-add-bitrate')?.value;
|
||||
if (br) t.min_bitrate = parseInt(br, 10);
|
||||
}
|
||||
t.label = rtLabel(t);
|
||||
currentRankedTargets.push(t);
|
||||
markQualityProfileCustom();
|
||||
renderRankedTargets();
|
||||
debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
|
|
@ -2102,45 +2059,23 @@ async function applyQualityPreset(presetName) {
|
|||
}
|
||||
|
||||
function collectQualityProfileFromUI() {
|
||||
const profile = {
|
||||
version: 2,
|
||||
preset: 'custom', // Will be overridden if a preset is active
|
||||
qualities: {},
|
||||
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true
|
||||
};
|
||||
|
||||
const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
|
||||
|
||||
qualities.forEach((quality, index) => {
|
||||
const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false;
|
||||
const minSlider = document.getElementById(`${quality}-min`);
|
||||
const maxSlider = document.getElementById(`${quality}-max`);
|
||||
|
||||
// Preserve priority from the currently loaded profile instead of using array order
|
||||
const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1);
|
||||
|
||||
profile.qualities[quality] = {
|
||||
enabled: enabled,
|
||||
min_kbps: parseInt(minSlider?.value || 0),
|
||||
max_kbps: parseInt(maxSlider?.value || 99999),
|
||||
priority: existingPriority
|
||||
};
|
||||
|
||||
// Add FLAC-specific bit_depth and fallback settings
|
||||
if (quality === 'flac') {
|
||||
const activeBtn = document.querySelector('.bit-depth-btn.active');
|
||||
profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any';
|
||||
const fallbackCb = document.getElementById('flac-bit-depth-fallback');
|
||||
profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true;
|
||||
}
|
||||
// v3: ordered target list. Drop empty/None fields so each target stays
|
||||
// minimal (matches QualityTarget.to_dict on the backend).
|
||||
const ranked_targets = currentRankedTargets.map(t => {
|
||||
const out = { format: t.format };
|
||||
if (t.label) out.label = t.label;
|
||||
if (t.bit_depth) out.bit_depth = t.bit_depth;
|
||||
if (t.min_sample_rate) out.min_sample_rate = t.min_sample_rate;
|
||||
if (t.min_bitrate) out.min_bitrate = t.min_bitrate;
|
||||
return out;
|
||||
});
|
||||
|
||||
// Check if current profile matches a preset
|
||||
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
|
||||
profile.preset = currentQualityProfile.preset;
|
||||
}
|
||||
|
||||
return profile;
|
||||
return {
|
||||
version: 3,
|
||||
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
|
||||
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
|
||||
ranked_targets,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveQualityProfile() {
|
||||
|
|
|
|||
|
|
@ -4090,6 +4090,150 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
/* Quality tier cards - upgraded inner cards */
|
||||
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
|
||||
.ranked-targets-editor {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ranked-targets-label {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ranked-targets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ranked-targets-empty {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ranked-target-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
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: 10px;
|
||||
transition: border-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ranked-target-row:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.ranked-target-row.rt-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ranked-target-row.rt-dragover {
|
||||
border-color: rgba(99, 179, 237, 0.7);
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-handle {
|
||||
cursor: grab;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-rank {
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-label {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-move,
|
||||
.ranked-target-row .rt-del {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-move:hover,
|
||||
.ranked-target-row .rt-del:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.ranked-target-row .rt-del:hover {
|
||||
background: rgba(229, 62, 62, 0.25);
|
||||
border-color: rgba(229, 62, 62, 0.5);
|
||||
}
|
||||
|
||||
.ranked-target-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ranked-target-add select,
|
||||
.ranked-target-add input[type="number"] {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.ranked-target-add .rt-inline-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ranked-target-add .rt-add-btn {
|
||||
background: rgba(99, 179, 237, 0.18);
|
||||
border: 1px solid rgba(99, 179, 237, 0.4);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.ranked-target-add .rt-add-btn:hover {
|
||||
background: rgba(99, 179, 237, 0.3);
|
||||
}
|
||||
|
||||
.quality-tier {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue