preferred quality updates.
This commit is contained in:
parent
9e3c64115b
commit
576f151c5c
6 changed files with 898 additions and 36 deletions
|
|
@ -236,6 +236,12 @@ class PlexClient:
|
||||||
if not self._is_connecting:
|
if not self._is_connecting:
|
||||||
self.ensure_connection()
|
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
|
return self.server is not None and self.music_library is not None
|
||||||
|
|
||||||
def get_all_playlists(self) -> List[PlexPlaylistInfo]:
|
def get_all_playlists(self) -> List[PlexPlaylistInfo]:
|
||||||
|
|
|
||||||
|
|
@ -1883,7 +1883,167 @@ class MusicDatabase:
|
||||||
def get_preference(self, key: str) -> Optional[str]:
|
def get_preference(self, key: str) -> Optional[str]:
|
||||||
"""Get a user preference (alias for get_metadata for clarity)"""
|
"""Get a user preference (alias for get_metadata for clarity)"""
|
||||||
return self.get_metadata(key)
|
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
|
# Wishlist management methods
|
||||||
|
|
||||||
def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed",
|
def add_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str = "Download failed",
|
||||||
|
|
|
||||||
209
web_server.py
209
web_server.py
|
|
@ -2062,6 +2062,98 @@ def select_plex_music_library():
|
||||||
logger.error(f"Error setting Plex music library: {e}")
|
logger.error(f"Error setting Plex music library: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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'])
|
@app.route('/api/detect-soulseek', methods=['POST'])
|
||||||
def detect_soulseek_endpoint():
|
def detect_soulseek_endpoint():
|
||||||
print("Received auto-detect request for slskd")
|
print("Received auto-detect request for slskd")
|
||||||
|
|
@ -7790,29 +7882,30 @@ def stop_database_update():
|
||||||
|
|
||||||
def _filter_candidates_by_quality_preference(candidates):
|
def _filter_candidates_by_quality_preference(candidates):
|
||||||
"""
|
"""
|
||||||
Filter candidates based on user's quality preference.
|
Filter candidates based on user's quality profile with file size constraints.
|
||||||
Returns candidates of the preferred quality, sorted by size (largest first for best quality).
|
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
|
from database.music_database import MusicDatabase
|
||||||
|
|
||||||
user_preference = config_manager.get_quality_preference() # flac, mp3_320, mp3_256, mp3_192, any
|
# Get quality profile from database
|
||||||
|
db = MusicDatabase()
|
||||||
|
profile = db.get_quality_profile()
|
||||||
|
|
||||||
# If user wants 'any' quality, return all candidates (already sorted by confidence+size)
|
print(f"🎵 [Quality Filter] Using profile preset: '{profile.get('preset', 'custom')}', filtering {len(candidates)} candidates")
|
||||||
if user_preference == 'any':
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
print(f"🎵 [Quality Filter] User preference: '{user_preference}', filtering {len(candidates)} candidates")
|
# Categorize candidates by quality with file size constraints
|
||||||
|
|
||||||
# Categorize candidates by quality
|
|
||||||
quality_buckets = {
|
quality_buckets = {
|
||||||
'flac': [],
|
'flac': [],
|
||||||
'mp3_320': [],
|
'mp3_320': [],
|
||||||
'mp3_256': [],
|
'mp3_256': [],
|
||||||
'mp3_192': [],
|
'mp3_192': [],
|
||||||
'mp3_low': [],
|
|
||||||
'other': []
|
'other': []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Track all candidates that pass size checks (for fallback)
|
||||||
|
size_filtered_all = []
|
||||||
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if not candidate.quality:
|
if not candidate.quality:
|
||||||
quality_buckets['other'].append(candidate)
|
quality_buckets['other'].append(candidate)
|
||||||
|
|
@ -7820,38 +7913,94 @@ def _filter_candidates_by_quality_preference(candidates):
|
||||||
|
|
||||||
track_format = candidate.quality.lower()
|
track_format = candidate.quality.lower()
|
||||||
track_bitrate = candidate.bitrate or 0
|
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':
|
if track_format == 'flac':
|
||||||
quality_buckets['flac'].append(candidate)
|
quality_config = profile['qualities'].get('flac', {})
|
||||||
elif track_format == 'mp3':
|
min_mb = quality_config.get('min_mb', 0)
|
||||||
if track_bitrate >= 320:
|
max_mb = quality_config.get('max_mb', 999)
|
||||||
quality_buckets['mp3_320'].append(candidate)
|
|
||||||
elif track_bitrate >= 256:
|
# Check if within size range
|
||||||
quality_buckets['mp3_256'].append(candidate)
|
if min_mb <= file_size_mb <= max_mb:
|
||||||
elif track_bitrate >= 192:
|
# Add to bucket if enabled
|
||||||
quality_buckets['mp3_192'].append(candidate)
|
if quality_config.get('enabled', False):
|
||||||
|
quality_buckets['flac'].append(candidate)
|
||||||
|
# Always track for fallback
|
||||||
|
size_filtered_all.append(candidate)
|
||||||
else:
|
else:
|
||||||
quality_buckets['mp3_low'].append(candidate)
|
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_key = 'mp3_320'
|
||||||
|
elif track_bitrate >= 256:
|
||||||
|
quality_key = 'mp3_256'
|
||||||
|
elif track_bitrate >= 192:
|
||||||
|
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:
|
||||||
|
print(f"🎵 [Quality Filter] {quality_key.upper()} file rejected: {file_size_mb:.1f}MB outside range {min_mb}-{max_mb}MB")
|
||||||
else:
|
else:
|
||||||
quality_buckets['other'].append(candidate)
|
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():
|
for bucket in quality_buckets.values():
|
||||||
bucket.sort(key=lambda x: x.size, reverse=True)
|
bucket.sort(key=lambda x: (x.quality_score, x.size), reverse=True)
|
||||||
|
|
||||||
# Return candidates matching user preference
|
|
||||||
preferred_candidates = quality_buckets.get(user_preference, [])
|
|
||||||
|
|
||||||
# Debug logging
|
# Debug logging
|
||||||
for quality, bucket in quality_buckets.items():
|
for quality, bucket in quality_buckets.items():
|
||||||
if bucket:
|
if bucket:
|
||||||
print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates")
|
print(f"🎵 [Quality Filter] Found {len(bucket)} '{quality}' candidates (after size filtering)")
|
||||||
|
|
||||||
if preferred_candidates:
|
# Waterfall priority logic: try qualities in priority order
|
||||||
print(f"🎯 [Quality Filter] Returning {len(preferred_candidates)} '{user_preference}' candidates")
|
# Build priority list from enabled qualities
|
||||||
return preferred_candidates
|
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:
|
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 []
|
return []
|
||||||
|
|
||||||
def get_valid_candidates(results, spotify_track, query):
|
def get_valid_candidates(results, spotify_track, query):
|
||||||
|
|
|
||||||
141
webui/index.html
141
webui/index.html
|
|
@ -1744,7 +1744,146 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Database Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>Database Settings</h3>
|
<h3>Database Settings</h3>
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ async function loadPageData(pageId) {
|
||||||
case 'settings':
|
case 'settings':
|
||||||
initializeSettings();
|
initializeSettings();
|
||||||
await loadSettingsData();
|
await loadSettingsData();
|
||||||
|
await loadQualityProfile();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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() {
|
async function saveSettings() {
|
||||||
// Determine active server from toggle buttons
|
// Determine active server from toggle buttons
|
||||||
let activeServer = 'plex';
|
let activeServer = 'plex';
|
||||||
|
|
@ -1575,19 +1788,26 @@ async function saveSettings() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showLoadingOverlay('Saving settings...');
|
showLoadingOverlay('Saving settings...');
|
||||||
|
|
||||||
|
// Save main settings
|
||||||
const response = await fetch(API.settings, {
|
const response = await fetch(API.settings, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(settings)
|
body: JSON.stringify(settings)
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
// Save quality profile
|
||||||
|
const qualityProfileSaved = await saveQualityProfile();
|
||||||
|
|
||||||
|
if (result.success && qualityProfileSaved) {
|
||||||
showToast('Settings saved successfully', 'success');
|
showToast('Settings saved successfully', 'success');
|
||||||
// Trigger immediate status update
|
// Trigger immediate status update
|
||||||
setTimeout(updateServiceStatus, 1000);
|
setTimeout(updateServiceStatus, 1000);
|
||||||
|
} else if (result.success && !qualityProfileSaved) {
|
||||||
|
showToast('Settings saved, but quality profile failed to save', 'warning');
|
||||||
|
setTimeout(updateServiceStatus, 1000);
|
||||||
} else {
|
} else {
|
||||||
showToast(`Failed to save settings: ${result.error}`, 'error');
|
showToast(`Failed to save settings: ${result.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1263,6 +1263,194 @@ body {
|
||||||
white-space: nowrap;
|
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 {
|
.test-button {
|
||||||
background: rgba(29, 185, 84, 0.1);
|
background: rgba(29, 185, 84, 0.1);
|
||||||
color: #1ed760;
|
color: #1ed760;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue