diff --git a/core/plex_client.py b/core/plex_client.py index 2aa56bcd..c030d9f6 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -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]: diff --git a/database/music_database.py b/database/music_database.py index acd78112..a45ee2c0 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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", diff --git a/web_server.py b/web_server.py index b72f2af0..9a3edf8d 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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): diff --git a/webui/index.html b/webui/index.html index 0eefa940..ea3c8f1a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1744,7 +1744,146 @@ - + + +
+

🎵 Quality Profile

+ + +
+ +
+ + + +
+
+ + +
+
+ + Priority: 1 +
+
+
+ +
+ + +
+
+
+ 0 MB + - + 150 MB +
+
+
+
+ + +
+
+ + Priority: 2 +
+
+
+ +
+ + +
+
+
+ 0 MB + - + 20 MB +
+
+
+
+ + +
+
+ + Priority: 3 +
+
+
+ +
+ + +
+
+
+ 0 MB + - + 15 MB +
+
+
+
+ + +
+
+ + Priority: 4 +
+
+
+ +
+ + +
+
+
+ 0 MB + - + 12 MB +
+
+
+
+ + +
+ +
+ +
+ How it works: 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). +
+
+

Database Settings

diff --git a/webui/static/script.js b/webui/static/script.js index 94d94a03..85bfb24c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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'); } diff --git a/webui/static/style.css b/webui/static/style.css index 457cef69..eb686300 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;