preferred quality updates.

pull/64/head
Broque Thomas 6 months ago
parent 9e3c64115b
commit 576f151c5c

@ -236,6 +236,12 @@ class PlexClient:
if not self._is_connecting:
self.ensure_connection()
# For status checks, only verify server connection, not music library
# Music library might be None if user hasn't selected one yet
return self.server is not None
def is_fully_configured(self) -> bool:
"""Check if both server is connected AND music library is selected."""
return self.server is not None and self.music_library is not None
def get_all_playlists(self) -> List[PlexPlaylistInfo]:

@ -1883,7 +1883,167 @@ class MusicDatabase:
def get_preference(self, key: str) -> Optional[str]:
"""Get a user preference (alias for get_metadata for clarity)"""
return self.get_metadata(key)
# Quality profile management methods
def get_quality_profile(self) -> dict:
"""Get the quality profile configuration, returns default if not set"""
import json
profile_json = self.get_preference('quality_profile')
if profile_json:
try:
return json.loads(profile_json)
except json.JSONDecodeError:
logger.error("Failed to parse quality profile JSON, returning default")
# Return smart defaults (balanced preset)
return {
"version": 1,
"preset": "balanced",
"qualities": {
"flac": {
"enabled": True,
"min_mb": 0,
"max_mb": 150,
"priority": 1
},
"mp3_320": {
"enabled": True,
"min_mb": 0,
"max_mb": 20,
"priority": 2
},
"mp3_256": {
"enabled": True,
"min_mb": 0,
"max_mb": 15,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_mb": 0,
"max_mb": 12,
"priority": 4
}
},
"fallback_enabled": True
}
def set_quality_profile(self, profile: dict) -> bool:
"""Save quality profile configuration"""
import json
try:
profile_json = json.dumps(profile)
self.set_preference('quality_profile', profile_json)
logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}")
return True
except Exception as e:
logger.error(f"Failed to save quality profile: {e}")
return False
def get_quality_preset(self, preset_name: str) -> dict:
"""Get a predefined quality preset"""
presets = {
"audiophile": {
"version": 1,
"preset": "audiophile",
"qualities": {
"flac": {
"enabled": True,
"min_mb": 0,
"max_mb": 200,
"priority": 1
},
"mp3_320": {
"enabled": False,
"min_mb": 0,
"max_mb": 20,
"priority": 2
},
"mp3_256": {
"enabled": False,
"min_mb": 0,
"max_mb": 15,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_mb": 0,
"max_mb": 12,
"priority": 4
}
},
"fallback_enabled": False
},
"balanced": {
"version": 1,
"preset": "balanced",
"qualities": {
"flac": {
"enabled": True,
"min_mb": 0,
"max_mb": 150,
"priority": 1
},
"mp3_320": {
"enabled": True,
"min_mb": 0,
"max_mb": 20,
"priority": 2
},
"mp3_256": {
"enabled": True,
"min_mb": 0,
"max_mb": 15,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_mb": 0,
"max_mb": 12,
"priority": 4
}
},
"fallback_enabled": True
},
"space_saver": {
"version": 1,
"preset": "space_saver",
"qualities": {
"flac": {
"enabled": False,
"min_mb": 0,
"max_mb": 150,
"priority": 4
},
"mp3_320": {
"enabled": True,
"min_mb": 0,
"max_mb": 15,
"priority": 1
},
"mp3_256": {
"enabled": True,
"min_mb": 0,
"max_mb": 12,
"priority": 2
},
"mp3_192": {
"enabled": True,
"min_mb": 0,
"max_mb": 10,
"priority": 3
}
},
"fallback_enabled": True
}
}
return presets.get(preset_name, presets["balanced"])
# Wishlist management methods
def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed",

@ -2062,6 +2062,98 @@ def select_plex_music_library():
logger.error(f"Error setting Plex music library: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == QUALITY PROFILE API ==
# ===============================
@app.route('/api/quality-profile', methods=['GET'])
def get_quality_profile():
"""Get current quality profile configuration"""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
profile = db.get_quality_profile()
return jsonify({
"success": True,
"profile": profile
})
except Exception as e:
logger.error(f"Error getting quality profile: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quality-profile', methods=['POST'])
def save_quality_profile():
"""Save quality profile configuration"""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No profile data provided"}), 400
success = db.set_quality_profile(data)
if success:
add_activity_item("🎵", "Quality Profile Updated", f"Preset: {data.get('preset', 'custom')}", "Now")
return jsonify({"success": True, "message": "Quality profile saved successfully"})
else:
return jsonify({"success": False, "error": "Failed to save quality profile"}), 500
except Exception as e:
logger.error(f"Error saving quality profile: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quality-profile/presets', methods=['GET'])
def get_quality_presets():
"""Get all available quality presets"""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
presets = {
"audiophile": db.get_quality_preset("audiophile"),
"balanced": db.get_quality_preset("balanced"),
"space_saver": db.get_quality_preset("space_saver")
}
return jsonify({
"success": True,
"presets": presets
})
except Exception as e:
logger.error(f"Error getting quality presets: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quality-profile/preset/<preset_name>', methods=['POST'])
def apply_quality_preset(preset_name):
"""Apply a predefined quality preset"""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
preset = db.get_quality_preset(preset_name)
success = db.set_quality_profile(preset)
if success:
add_activity_item("🎵", "Quality Preset Applied", f"Applied '{preset_name}' preset", "Now")
return jsonify({
"success": True,
"message": f"Applied '{preset_name}' preset",
"profile": preset
})
else:
return jsonify({"success": False, "error": "Failed to apply preset"}), 500
except Exception as e:
logger.error(f"Error applying quality preset: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == END QUALITY PROFILE API ==
# ===============================
@app.route('/api/detect-soulseek', methods=['POST'])
def detect_soulseek_endpoint():
print("Received auto-detect request for slskd")
@ -7790,29 +7882,30 @@ def stop_database_update():
def _filter_candidates_by_quality_preference(candidates):
"""
Filter candidates based on user's quality preference.
Returns candidates of the preferred quality, sorted by size (largest first for best quality).
Filter candidates based on user's quality profile with file size constraints.
Uses priority waterfall logic: tries highest priority quality first, falls back to lower priorities.
Returns candidates matching quality profile constraints, sorted by confidence and size.
"""
from config.settings import config_manager
user_preference = config_manager.get_quality_preference() # flac, mp3_320, mp3_256, mp3_192, any
from database.music_database import MusicDatabase
# If user wants 'any' quality, return all candidates (already sorted by confidence+size)
if user_preference == 'any':
return candidates
# Get quality profile from database
db = MusicDatabase()
profile = db.get_quality_profile()
print(f"🎵 [Quality Filter] User preference: '{user_preference}', filtering {len(candidates)} candidates")
print(f"🎵 [Quality Filter] Using profile preset: '{profile.get('preset', 'custom')}', filtering {len(candidates)} candidates")
# Categorize candidates by quality
# Categorize candidates by quality with file size constraints
quality_buckets = {
'flac': [],
'mp3_320': [],
'mp3_256': [],
'mp3_192': [],
'mp3_low': [],
'other': []
}
# Track all candidates that pass size checks (for fallback)
size_filtered_all = []
for candidate in candidates:
if not candidate.quality:
quality_buckets['other'].append(candidate)
@ -7820,38 +7913,94 @@ def _filter_candidates_by_quality_preference(candidates):
track_format = candidate.quality.lower()
track_bitrate = candidate.bitrate or 0
file_size_mb = candidate.size / (1024 * 1024) # Convert bytes to MB
# Categorize and apply file size constraints
if track_format == 'flac':
quality_buckets['flac'].append(candidate)
quality_config = profile['qualities'].get('flac', {})
min_mb = quality_config.get('min_mb', 0)
max_mb = quality_config.get('max_mb', 999)
# Check if within size range
if min_mb <= file_size_mb <= max_mb:
# Add to bucket if enabled
if quality_config.get('enabled', False):
quality_buckets['flac'].append(candidate)
# Always track for fallback
size_filtered_all.append(candidate)
else:
print(f"🎵 [Quality Filter] FLAC file rejected: {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB")
elif track_format == 'mp3':
# Determine MP3 quality tier based on bitrate
if track_bitrate >= 320:
quality_buckets['mp3_320'].append(candidate)
quality_key = 'mp3_320'
elif track_bitrate >= 256:
quality_buckets['mp3_256'].append(candidate)
quality_key = 'mp3_256'
elif track_bitrate >= 192:
quality_buckets['mp3_192'].append(candidate)
quality_key = 'mp3_192'
else:
quality_buckets['other'].append(candidate)
continue
quality_config = profile['qualities'].get(quality_key, {})
min_mb = quality_config.get('min_mb', 0)
max_mb = quality_config.get('max_mb', 999)
# Check if within size range
if min_mb <= file_size_mb <= max_mb:
# Add to bucket if enabled
if quality_config.get('enabled', False):
quality_buckets[quality_key].append(candidate)
# Always track for fallback
size_filtered_all.append(candidate)
else:
quality_buckets['mp3_low'].append(candidate)
print(f"🎵 [Quality Filter] {quality_key.upper()} file rejected: {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB")
else:
quality_buckets['other'].append(candidate)
# Sort each bucket by size (largest first) to get best quality within each category
# Sort each bucket by quality score and size
for bucket in quality_buckets.values():
bucket.sort(key=lambda x: x.size, reverse=True)
# Return candidates matching user preference
preferred_candidates = quality_buckets.get(user_preference, [])
bucket.sort(key=lambda x: (x.quality_score, x.size), reverse=True)
# Debug logging
for quality, bucket in quality_buckets.items():
if bucket:
print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates")
if preferred_candidates:
print(f"🎯 [Quality Filter] Returning {len(preferred_candidates)} '{user_preference}' candidates")
return preferred_candidates
print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates (after size filtering)")
# Waterfall priority logic: try qualities in priority order
# Build priority list from enabled qualities
quality_priorities = []
for quality_name, quality_config in profile['qualities'].items():
if quality_config.get('enabled', False):
priority = quality_config.get('priority', 999)
quality_priorities.append((priority, quality_name))
# Sort by priority (lower number = higher priority)
quality_priorities.sort()
# Try each quality in priority order
for priority, quality_name in quality_priorities:
candidates_for_quality = quality_buckets.get(quality_name, [])
if candidates_for_quality:
print(f"🎯 [Quality Filter] Returning {len(candidates_for_quality)} '{quality_name}' candidates (priority {priority})")
return candidates_for_quality
# If no enabled qualities matched, check if fallback is enabled
if profile.get('fallback_enabled', True):
print(f"⚠️ [Quality Filter] No enabled qualities matched, falling back to size-filtered candidates")
# Return candidates that passed size checks (even if quality disabled)
# This respects file size constraints while allowing any quality
if size_filtered_all:
size_filtered_all.sort(key=lambda x: (x.quality_score, x.size), reverse=True)
print(f"🎯 [Quality Filter] Returning {len(size_filtered_all)} fallback candidates (size-filtered, any quality)")
return size_filtered_all
else:
# All candidates failed size checks - respect user's constraints and fail
print(f"❌ [Quality Filter] All candidates failed size checks, returning empty (respecting size constraints)")
return []
else:
print(f"⚠️ [Quality Filter] No '{user_preference}' candidates found, will fall back to all")
print(f"❌ [Quality Filter] No enabled qualities matched and fallback is disabled, returning empty")
return []
def get_valid_candidates(results, spotify_track, query):

@ -1744,7 +1744,146 @@
</div>
</div>
</div>
<!-- Quality Profile Settings -->
<div class="settings-group">
<h3>🎵 Quality Profile</h3>
<!-- Presets -->
<div class="quality-presets">
<label>Quick Presets:</label>
<div class="preset-buttons">
<button class="preset-button" onclick="applyQualityPreset('audiophile')" title="FLAC only, strict size constraints">
🎧 Audiophile
</button>
<button class="preset-button active" onclick="applyQualityPreset('balanced')" title="FLAC preferred, MP3 fallback">
⚖️ Balanced
</button>
<button class="preset-button" onclick="applyQualityPreset('space_saver')" title="MP3 preferred, smaller sizes">
💾 Space Saver
</button>
</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>
</div>
<div class="quality-tier-sliders" id="sliders-flac">
<div class="slider-group">
<label>File Size Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="flac-min" min="0" max="200" value="0" step="5" oninput="updateQualityRange('flac')">
<input type="range" class="range-slider range-slider-max" id="flac-max" min="0" max="200" value="150" step="5" oninput="updateQualityRange('flac')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="flac-min-value">0 MB</span>
<span>-</span>
<span id="flac-max-value">150 MB</span>
</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>File Size Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="mp3_320-min" min="0" max="50" value="0" step="1" oninput="updateQualityRange('mp3_320')">
<input type="range" class="range-slider range-slider-max" id="mp3_320-max" min="0" max="50" value="20" step="1" oninput="updateQualityRange('mp3_320')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_320-min-value">0 MB</span>
<span>-</span>
<span id="mp3_320-max-value">20 MB</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>File Size Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="mp3_256-min" min="0" max="40" value="0" step="1" oninput="updateQualityRange('mp3_256')">
<input type="range" class="range-slider range-slider-max" id="mp3_256-max" min="0" max="40" value="15" step="1" oninput="updateQualityRange('mp3_256')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_256-min-value">0 MB</span>
<span>-</span>
<span id="mp3_256-max-value">15 MB</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>File Size Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="mp3_192-min" min="0" max="30" value="0" step="1" oninput="updateQualityRange('mp3_192')">
<input type="range" class="range-slider range-slider-max" id="mp3_192-max" min="0" max="30" value="12" step="1" oninput="updateQualityRange('mp3_192')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_192-min-value">0 MB</span>
<span>-</span>
<span id="mp3_192-max-value">12 MB</span>
</div>
</div>
</div>
</div>
<!-- Fallback Option -->
<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
</label>
</div>
<div class="help-text">
<strong>How it works:</strong> Downloads try each enabled quality in priority order (1 = highest).
File size constraints filter out outliers - MAX limits catch fake files (500MB "FLACs"), MIN limits (optional) can enforce minimum quality.
Set MIN to 0 to accept all file sizes (recommended for most users).
</div>
</div>
<!-- Database Settings -->
<div class="settings-group">
<h3>Database Settings</h3>

@ -442,6 +442,7 @@ async function loadPageData(pageId) {
case 'settings':
initializeSettings();
await loadSettingsData();
await loadQualityProfile();
break;
}
} catch (error) {
@ -1518,6 +1519,218 @@ function toggleServer(serverType) {
}
}
// ===============================
// QUALITY PROFILE FUNCTIONS
// ===============================
let currentQualityProfile = null;
async function loadQualityProfile() {
try {
const response = await fetch('/api/quality-profile');
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
}
} catch (error) {
console.error('Error loading quality profile:', error);
}
}
function populateQualityProfileUI(profile) {
// Update preset buttons
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');
}
// 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;
}
// Set min/max sliders
const minSlider = document.getElementById(`${quality}-min`);
const maxSlider = document.getElementById(`${quality}-max`);
if (minSlider && maxSlider) {
minSlider.value = config.min_mb;
maxSlider.value = config.max_mb;
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');
}
}
}
});
// Set fallback checkbox
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) {
fallbackCheckbox.checked = profile.fallback_enabled;
}
}
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`);
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;
}
// Ensure max doesn't go below min
if (max < min) {
max = min;
maxSlider.value = max;
}
minValue.textContent = `${min} MB`;
maxValue.textContent = `${max} MB`;
}
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');
}
}
// Mark preset as custom when manually changing
if (currentQualityProfile) {
currentQualityProfile.preset = 'custom';
document.querySelectorAll('.preset-button').forEach(btn => {
btn.classList.remove('active');
});
}
}
async function applyQualityPreset(presetName) {
try {
showLoadingOverlay(`Applying ${presetName} preset...`);
const response = await fetch(`/api/quality-profile/preset/${presetName}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
currentQualityProfile = data.profile;
populateQualityProfileUI(currentQualityProfile);
showToast(`Applied '${presetName}' preset`, 'success');
} else {
showToast(`Failed to apply preset: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error applying quality preset:', error);
showToast('Failed to apply preset', 'error');
} finally {
hideLoadingOverlay();
}
}
function collectQualityProfileFromUI() {
const profile = {
version: 1,
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'];
let priority = 1;
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`);
profile.qualities[quality] = {
enabled: enabled,
min_mb: parseInt(minSlider?.value || 0),
max_mb: parseInt(maxSlider?.value || 999),
priority: index + 1 // 1-4 based on order
};
});
// Check if current profile matches a preset
if (currentQualityProfile && currentQualityProfile.preset !== 'custom') {
profile.preset = currentQualityProfile.preset;
}
return profile;
}
async function saveQualityProfile() {
try {
const profile = collectQualityProfileFromUI();
const response = await fetch('/api/quality-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(profile)
});
const data = await response.json();
if (data.success) {
currentQualityProfile = profile;
console.log('Quality profile saved successfully');
return true;
} else {
console.error('Failed to save quality profile:', data.error);
return false;
}
} catch (error) {
console.error('Error saving quality profile:', error);
return false;
}
}
// ===============================
// END QUALITY PROFILE FUNCTIONS
// ===============================
async function saveSettings() {
// Determine active server from toggle buttons
let activeServer = 'plex';
@ -1575,19 +1788,26 @@ async function saveSettings() {
try {
showLoadingOverlay('Saving settings...');
// Save main settings
const response = await fetch(API.settings, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
const result = await response.json();
if (result.success) {
// Save quality profile
const qualityProfileSaved = await saveQualityProfile();
if (result.success && qualityProfileSaved) {
showToast('Settings saved successfully', 'success');
// Trigger immediate status update
setTimeout(updateServiceStatus, 1000);
} else if (result.success && !qualityProfileSaved) {
showToast('Settings saved, but quality profile failed to save', 'warning');
setTimeout(updateServiceStatus, 1000);
} else {
showToast(`Failed to save settings: ${result.error}`, 'error');
}

@ -1263,6 +1263,194 @@ body {
white-space: nowrap;
}
/* ===== QUALITY PROFILE STYLES ===== */
.quality-presets {
margin-bottom: 20px;
}
.quality-presets label {
display: block;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: 500;
}
.preset-buttons {
display: flex;
gap: 8px;
}
.preset-button {
flex: 1;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
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;
transition: all 0.3s ease;
white-space: nowrap;
}
.preset-button:hover {
background: rgba(29, 185, 84, 0.1);
border-color: rgba(29, 185, 84, 0.3);
color: #1ed760;
}
.preset-button.active {
background: rgba(29, 185, 84, 0.15);
border-color: rgba(29, 185, 84, 0.5);
color: #1ed760;
font-weight: 600;
}
.quality-tier {
margin-bottom: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.quality-tier-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.quality-tier-name {
color: rgba(255, 255, 255, 0.95);
font-size: 12px;
font-weight: 600;
}
.quality-tier-priority {
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
font-weight: 500;
}
.quality-tier-sliders {
padding-left: 24px;
opacity: 1;
transition: opacity 0.3s ease;
}
.quality-tier-sliders.disabled {
opacity: 0.4;
pointer-events: none;
}
.slider-group {
margin-top: 8px;
}
.slider-group label {
display: block;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
}
.dual-slider-container {
position: relative;
height: 40px;
margin: 10px 0;
}
.range-slider {
position: absolute;
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
pointer-events: none;
}
.range-slider::-webkit-slider-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #1ed760;
cursor: pointer;
pointer-events: all;
border: 2px solid #0a0a0a;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.range-slider::-webkit-slider-thumb:hover {
background: #1fdf64;
transform: scale(1.1);
}
.range-slider::-moz-range-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.range-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #1ed760;
cursor: pointer;
pointer-events: all;
border: 2px solid #0a0a0a;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.range-slider::-moz-range-thumb:hover {
background: #1fdf64;
transform: scale(1.1);
}
.range-slider-track {
position: absolute;
top: 18px;
width: 100%;
height: 4px;
background: rgba(29, 185, 84, 0.3);
border-radius: 2px;
pointer-events: none;
}
.slider-values {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
font-weight: 500;
}
.slider-values span:first-child,
.slider-values span:last-child {
color: #1ed760;
font-weight: 600;
}
/* ===== END QUALITY PROFILE STYLES ===== */
.test-button {
background: rgba(29, 185, 84, 0.1);
color: #1ed760;

Loading…
Cancel
Save