Redesign hybrid mode with N-source priority ordering
Replace fixed primary/secondary hybrid dropdowns with an ordered list of all 5 download sources. Users enable/disable each source and reorder with up/down arrows to set download priority. Sources are tried in order until one returns results. - New hybrid_order config field (backward compat with legacy primary/secondary) - Download orchestrator loops ordered list with per-source error handling - Sortable source list UI with icons, toggle switches, priority numbers - Source-specific settings shown for all enabled hybrid sources - Seamless migration from legacy 2-source to N-source format
This commit is contained in:
parent
3293a476fc
commit
fc4e16337a
6 changed files with 306 additions and 58 deletions
|
|
@ -366,8 +366,9 @@ class ConfigManager:
|
|||
},
|
||||
"download_source": {
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
|
||||
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
||||
"hybrid_secondary": "youtube", # Fallback source if primary finds nothing
|
||||
"hybrid_primary": "soulseek", # Legacy: primary source for hybrid mode
|
||||
"hybrid_secondary": "youtube", # Legacy: fallback source for hybrid mode
|
||||
"hybrid_order": [], # Ordered list of sources for hybrid mode (overrides primary/secondary)
|
||||
},
|
||||
"tidal_download": {
|
||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||
|
|
|
|||
|
|
@ -46,16 +46,21 @@ class DownloadOrchestrator:
|
|||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek')
|
||||
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
|
||||
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
|
||||
|
||||
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
|
||||
if self.mode == 'hybrid':
|
||||
logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}")
|
||||
if self.hybrid_order:
|
||||
logger.info(f" Source priority: {' → '.join(self.hybrid_order)}")
|
||||
else:
|
||||
logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}")
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload settings from config (call after settings change)"""
|
||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek')
|
||||
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
|
||||
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
|
||||
|
||||
# Reload underlying client configs (SLSKD URL, API key, etc.)
|
||||
self.soulseek._setup_client()
|
||||
|
|
@ -80,7 +85,9 @@ class DownloadOrchestrator:
|
|||
elif self.mode == 'hifi':
|
||||
return self.hifi.is_configured()
|
||||
elif self.mode == 'hybrid':
|
||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() or self.hifi.is_configured()
|
||||
clients = {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, 'hifi': self.hifi}
|
||||
sources = self.hybrid_order if self.hybrid_order else [self.hybrid_primary, self.hybrid_secondary]
|
||||
return any(clients[s].is_configured() for s in sources if s in clients)
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -153,30 +160,39 @@ class DownloadOrchestrator:
|
|||
'qobuz': self.qobuz,
|
||||
'hifi': self.hifi,
|
||||
}
|
||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
|
||||
|
||||
# Ensure secondary differs from primary
|
||||
if secondary == primary:
|
||||
# Pick first available source that isn't primary
|
||||
secondary = next((name for name in clients if name != primary), 'soulseek')
|
||||
# Build ordered source list: prefer hybrid_order, fall back to legacy primary/secondary
|
||||
if self.hybrid_order:
|
||||
source_order = [s for s in self.hybrid_order if s in clients]
|
||||
else:
|
||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
|
||||
if secondary == primary:
|
||||
secondary = next((name for name in clients if name != primary), 'soulseek')
|
||||
source_order = [primary, secondary]
|
||||
|
||||
# Try primary source first
|
||||
logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
|
||||
tracks, albums = await clients[primary].search(query, timeout, progress_callback)
|
||||
if tracks:
|
||||
logger.info(f"✅ {primary} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
if not source_order:
|
||||
source_order = ['soulseek']
|
||||
|
||||
# Try secondary fallback
|
||||
logger.info(f"🔄 {primary} found nothing, trying {secondary} fallback")
|
||||
tracks, albums = await clients[secondary].search(query, timeout, progress_callback)
|
||||
if tracks:
|
||||
logger.info(f"✅ {secondary} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
logger.info(f"🔍 Hybrid search ({' → '.join(source_order)}): {query}")
|
||||
|
||||
# Nothing found from either source
|
||||
logger.warning(f"❌ Hybrid search: both {primary} and {secondary} found nothing for: {query}")
|
||||
# Try each source in priority order
|
||||
for i, source_name in enumerate(source_order):
|
||||
try:
|
||||
if i == 0:
|
||||
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}")
|
||||
else:
|
||||
logger.info(f"🔄 Trying {source_name} (priority {i+1}): {query}")
|
||||
|
||||
tracks, albums = await clients[source_name].search(query, timeout, progress_callback)
|
||||
if tracks:
|
||||
logger.info(f"✅ {source_name} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ {source_name} search failed: {e}")
|
||||
|
||||
# Nothing found from any source
|
||||
logger.warning(f"❌ Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
|
||||
return ([], [])
|
||||
|
||||
# Fallback: empty results
|
||||
|
|
|
|||
|
|
@ -6997,7 +6997,9 @@ def stream_enhanced_search_track():
|
|||
search_queries = []
|
||||
import re
|
||||
|
||||
if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz', 'hifi')):
|
||||
_hybrid_order = config_manager.get('download_source.hybrid_order', [])
|
||||
_hybrid_first = _hybrid_order[0] if _hybrid_order else config_manager.get('download_source.hybrid_primary', 'soulseek')
|
||||
if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and _hybrid_first in ('youtube', 'tidal', 'qobuz', 'hifi')):
|
||||
# YouTube/Tidal mode: Include artist for better context
|
||||
# Primary query: Artist + Track
|
||||
if artist_name and track_name:
|
||||
|
|
@ -21876,8 +21878,10 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
|||
preflight_source = None
|
||||
preflight_tracks = None
|
||||
dl_source_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
_dl_hybrid_order = config_manager.get('download_source.hybrid_order', [])
|
||||
_dl_hybrid_first = _dl_hybrid_order[0] if _dl_hybrid_order else config_manager.get('download_source.hybrid_primary', 'soulseek')
|
||||
soulseek_is_source = dl_source_mode == 'soulseek' or (
|
||||
dl_source_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary', 'soulseek') == 'soulseek'
|
||||
dl_source_mode == 'hybrid' and _dl_hybrid_first == 'soulseek'
|
||||
)
|
||||
if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source:
|
||||
artist_name = batch_artist_context.get('name', '')
|
||||
|
|
|
|||
|
|
@ -3875,33 +3875,15 @@
|
|||
|
||||
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
|
||||
<div id="hybrid-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Primary Source:</label>
|
||||
<select id="hybrid-primary-source" class="form-select" onchange="updateHybridSecondaryOptions()">
|
||||
<option value="soulseek">Soulseek</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="tidal">Tidal</option>
|
||||
<option value="qobuz">Qobuz</option>
|
||||
<option value="hifi">HiFi</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
First source to try for downloads.
|
||||
</div>
|
||||
<div class="setting-help-text">
|
||||
Drag to reorder source priority. Enable sources you want to use. Downloads try each enabled source in order.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Fallback Source:</label>
|
||||
<select id="hybrid-secondary-source" class="form-select" onchange="updateDownloadSourceUI()">
|
||||
<option value="soulseek">Soulseek</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="tidal">Tidal</option>
|
||||
<option value="qobuz">Qobuz</option>
|
||||
<option value="hifi">HiFi</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
If the primary source finds nothing, try this source next.
|
||||
</div>
|
||||
<div class="hybrid-source-list" id="hybrid-source-list">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Hidden selects for backward compatibility with saveSettings -->
|
||||
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
|
||||
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
|
||||
</div>
|
||||
|
||||
<!-- Soulseek Settings (shown when soulseek is active source) -->
|
||||
|
|
|
|||
|
|
@ -4877,6 +4877,143 @@ function toggleStgService(el) {
|
|||
if (service) service.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
// ── Hybrid source priority list (drag-and-drop) ──
|
||||
const HYBRID_SOURCES = [
|
||||
{ id: 'soulseek', name: 'Soulseek', icon: 'https://raw.githubusercontent.com/slskd/slskd/master/docs/icon.png', emoji: '🎵' },
|
||||
{ id: 'youtube', name: 'YouTube', icon: 'https://www.svgrepo.com/show/13671/youtube.svg', emoji: '▶️' },
|
||||
{ id: 'tidal', name: 'Tidal', icon: 'https://www.svgrepo.com/show/519734/tidal.svg', emoji: '🌊' },
|
||||
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
||||
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
|
||||
];
|
||||
|
||||
let _hybridSourceOrder = ['soulseek', 'youtube'];
|
||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false };
|
||||
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
||||
|
||||
function buildHybridSourceList() {
|
||||
const container = document.getElementById('hybrid-source-list');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
// Build visual order: use persisted visual order, or enabled first + disabled at bottom
|
||||
if (!_hybridVisualOrder) {
|
||||
_hybridVisualOrder = [..._hybridSourceOrder];
|
||||
for (const src of HYBRID_SOURCES) {
|
||||
if (!_hybridVisualOrder.includes(src.id)) _hybridVisualOrder.push(src.id);
|
||||
}
|
||||
}
|
||||
const allIds = _hybridVisualOrder;
|
||||
|
||||
allIds.forEach((srcId, idx) => {
|
||||
const src = HYBRID_SOURCES.find(s => s.id === srcId);
|
||||
if (!src) return;
|
||||
const enabled = _hybridSourceEnabled[srcId] !== false;
|
||||
const isInOrder = _hybridSourceOrder.includes(srcId);
|
||||
const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
|
||||
item.draggable = true;
|
||||
item.dataset.sourceId = srcId;
|
||||
|
||||
item.innerHTML = `
|
||||
<span class="hybrid-source-arrows">
|
||||
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', -1)" title="Move up">▲</button>
|
||||
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', 1)" title="Move down">▼</button>
|
||||
</span>
|
||||
${src.icon
|
||||
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
|
||||
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
|
||||
}
|
||||
<span class="hybrid-source-name">${src.name}</span>
|
||||
<span class="hybrid-source-priority">${priorityNum}</span>
|
||||
<label class="hybrid-source-toggle">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
`;
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
// Sync hidden selects for backward compat
|
||||
_syncHybridHiddenSelects();
|
||||
}
|
||||
|
||||
function moveHybridSource(srcId, direction) {
|
||||
if (!_hybridVisualOrder) return;
|
||||
const idx = _hybridVisualOrder.indexOf(srcId);
|
||||
if (idx < 0) return;
|
||||
const newIdx = idx + direction;
|
||||
if (newIdx < 0 || newIdx >= _hybridVisualOrder.length) return;
|
||||
|
||||
// Swap in visual order
|
||||
[_hybridVisualOrder[idx], _hybridVisualOrder[newIdx]] = [_hybridVisualOrder[newIdx], _hybridVisualOrder[idx]];
|
||||
|
||||
// Rebuild enabled order from visual order
|
||||
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
|
||||
buildHybridSourceList();
|
||||
updateDownloadSourceUI();
|
||||
}
|
||||
|
||||
function toggleHybridSource(srcId, enabled) {
|
||||
_hybridSourceEnabled[srcId] = enabled;
|
||||
// Rebuild enabled order from visual order so priority matches position
|
||||
if (_hybridVisualOrder) {
|
||||
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
|
||||
}
|
||||
buildHybridSourceList();
|
||||
updateDownloadSourceUI();
|
||||
}
|
||||
|
||||
function _syncHybridOrderFromDOM() {
|
||||
const container = document.getElementById('hybrid-source-list');
|
||||
if (!container) return;
|
||||
const items = container.querySelectorAll('.hybrid-source-item');
|
||||
const newOrder = [];
|
||||
items.forEach(item => {
|
||||
const id = item.dataset.sourceId;
|
||||
if (_hybridSourceEnabled[id] !== false) {
|
||||
newOrder.push(id);
|
||||
}
|
||||
});
|
||||
_hybridSourceOrder = newOrder;
|
||||
}
|
||||
|
||||
function _syncHybridHiddenSelects() {
|
||||
// Keep hidden selects in sync for backward compat with saveSettings
|
||||
const primary = document.getElementById('hybrid-primary-source');
|
||||
const secondary = document.getElementById('hybrid-secondary-source');
|
||||
if (primary && _hybridSourceOrder.length > 0) primary.value = _hybridSourceOrder[0];
|
||||
if (secondary && _hybridSourceOrder.length > 1) secondary.value = _hybridSourceOrder[1];
|
||||
}
|
||||
|
||||
function getHybridOrder() {
|
||||
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
|
||||
}
|
||||
|
||||
function loadHybridSourceOrder(settings) {
|
||||
const order = settings.download_source?.hybrid_order;
|
||||
if (order && Array.isArray(order) && order.length > 0) {
|
||||
_hybridSourceOrder = order;
|
||||
_hybridSourceEnabled = {};
|
||||
for (const src of HYBRID_SOURCES) {
|
||||
_hybridSourceEnabled[src.id] = order.includes(src.id);
|
||||
}
|
||||
} else {
|
||||
// Legacy: fall back to primary/secondary
|
||||
const primary = settings.download_source?.hybrid_primary || 'soulseek';
|
||||
const secondary = settings.download_source?.hybrid_secondary || 'youtube';
|
||||
_hybridSourceOrder = [primary, secondary];
|
||||
_hybridSourceEnabled = {};
|
||||
for (const src of HYBRID_SOURCES) {
|
||||
_hybridSourceEnabled[src.id] = src.id === primary || src.id === secondary;
|
||||
}
|
||||
}
|
||||
_hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it
|
||||
buildHybridSourceList();
|
||||
}
|
||||
|
||||
async function loadSettingsData() {
|
||||
try {
|
||||
const response = await fetch(API.settings);
|
||||
|
|
@ -4972,9 +5109,7 @@ async function loadSettingsData() {
|
|||
|
||||
// Populate Download Source settings
|
||||
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
|
||||
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
|
||||
document.getElementById('hybrid-secondary-source').value = settings.download_source?.hybrid_secondary || 'youtube';
|
||||
updateHybridSecondaryOptions();
|
||||
loadHybridSourceOrder(settings);
|
||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
|
||||
|
|
@ -5225,10 +5360,10 @@ function updateDownloadSourceUI() {
|
|||
// Determine which sources are active
|
||||
let activeSources = new Set();
|
||||
if (mode === 'hybrid') {
|
||||
const primary = document.getElementById('hybrid-primary-source').value;
|
||||
const secondary = document.getElementById('hybrid-secondary-source').value;
|
||||
activeSources.add(primary);
|
||||
activeSources.add(secondary);
|
||||
const order = getHybridOrder();
|
||||
for (const src of order) activeSources.add(src);
|
||||
// Fallback: if no sources enabled, at least show soulseek
|
||||
if (activeSources.size === 0) activeSources.add('soulseek');
|
||||
} else {
|
||||
activeSources.add(mode);
|
||||
}
|
||||
|
|
@ -5888,6 +6023,7 @@ async function saveSettings(quiet = false) {
|
|||
mode: document.getElementById('download-source-mode').value,
|
||||
hybrid_primary: document.getElementById('hybrid-primary-source').value,
|
||||
hybrid_secondary: document.getElementById('hybrid-secondary-source').value,
|
||||
hybrid_order: getHybridOrder(),
|
||||
},
|
||||
tidal_download: {
|
||||
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
||||
|
|
|
|||
|
|
@ -43657,6 +43657,115 @@ tr.tag-diff-same {
|
|||
max-width: 340px;
|
||||
}
|
||||
|
||||
/* ── Hybrid source priority list (drag and drop) ── */
|
||||
.hybrid-source-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.hybrid-source-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.hybrid-source-item:hover { border-color: rgba(255, 255, 255, 0.12); }
|
||||
.hybrid-source-item.disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.hybrid-source-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hybrid-arrow-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 0.6em;
|
||||
cursor: pointer;
|
||||
padding: 1px 4px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.hybrid-arrow-btn:hover {
|
||||
color: var(--accent-color, #1db954);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.hybrid-source-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.hybrid-source-icon.emoji-icon {
|
||||
font-size: 1.2em;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hybrid-source-name {
|
||||
flex: 1;
|
||||
font-size: 0.9em;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hybrid-source-priority {
|
||||
font-size: 0.75em;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.hybrid-source-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hybrid-source-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.hybrid-source-toggle .toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
.hybrid-source-toggle .toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
.hybrid-source-toggle input:checked + .toggle-track {
|
||||
background: var(--accent-color, #1db954);
|
||||
}
|
||||
.hybrid-source-toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Accent color picker ── */
|
||||
#settings-page .accent-color-selector {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue