Merge pull request #725 from Nezreka/feat/basic-search-redesign
Basic search: visual overhaul + per-source picker in hybrid mode
This commit is contained in:
commit
1b5056ef2a
8 changed files with 1029 additions and 104 deletions
|
|
@ -1,28 +1,59 @@
|
|||
"""Basic Soulseek file search — flat list of file results sorted by quality.
|
||||
"""Basic download-source file search — flat list of file results sorted by quality.
|
||||
|
||||
Used by the Soulseek source icon in the unified search UI and by direct
|
||||
/api/search calls. Synchronous wrapper around the async soulseek client.
|
||||
Used by the basic search UI on the Search page and by ``/api/search``.
|
||||
|
||||
``run_basic_search`` replaced ``run_basic_soulseek_search`` so the caller
|
||||
can target any active download source (not just slskd). The old name is
|
||||
kept as a thin alias for backwards compat with any callers outside this
|
||||
module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_basic_soulseek_search(
|
||||
def run_basic_search(
|
||||
query: str,
|
||||
download_orchestrator,
|
||||
run_async: Callable,
|
||||
*,
|
||||
source: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list.
|
||||
"""Search ``source`` (or the active/first hybrid source) for ``query``.
|
||||
|
||||
Returns dicts with `result_type` set to "album" or "track" and sorted by
|
||||
`quality_score` descending. Empty list on any failure (caller logs).
|
||||
Returns dicts with ``result_type`` set to ``"album"`` or ``"track"``
|
||||
and sorted by ``quality_score`` descending. Empty list on any failure.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source:
|
||||
Optional source name to override the orchestrator's default selection.
|
||||
Must be a canonical name from ``DownloadPluginRegistry`` (e.g.
|
||||
``"soulseek"``, ``"tidal"``, ``"qobuz"``). When ``None``, behaviour
|
||||
is unchanged from before: orchestrator.search() picks the active
|
||||
source (single mode) or the first in chain (hybrid).
|
||||
"""
|
||||
tracks, albums = run_async(download_orchestrator.search(query))
|
||||
if source and download_orchestrator:
|
||||
# Target a specific source: resolve the client and call search()
|
||||
# directly instead of going through the orchestrator chain.
|
||||
try:
|
||||
client = download_orchestrator.client(source)
|
||||
except Exception as exc:
|
||||
logger.warning("basic search: could not resolve client for %r: %s", source, exc)
|
||||
client = None
|
||||
|
||||
if client is None:
|
||||
logger.warning("basic search: no client for source %r — falling back to orchestrator", source)
|
||||
tracks, albums = run_async(download_orchestrator.search(query))
|
||||
else:
|
||||
logger.info("basic search: targeting %r for %r", source, query)
|
||||
tracks, albums = run_async(client.search(query))
|
||||
else:
|
||||
tracks, albums = run_async(download_orchestrator.search(query))
|
||||
|
||||
processed_albums = []
|
||||
for album in albums:
|
||||
|
|
@ -42,3 +73,7 @@ def run_basic_soulseek_search(
|
|||
key=lambda x: x.get('quality_score', 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
# Backwards-compat alias for any callers that haven't been updated yet.
|
||||
run_basic_soulseek_search = run_basic_search
|
||||
|
|
|
|||
|
|
@ -96,3 +96,123 @@ def test_missing_quality_score_treated_as_zero():
|
|||
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
||||
# has_score (0.5) ranks above no_score (treated as 0)
|
||||
assert result[0]['name'] == 'h'
|
||||
|
||||
|
||||
# ── Source-targeted search (new in basic-search redesign) ──────────────
|
||||
|
||||
|
||||
class _FakeOrchestratorMulti:
|
||||
"""Orchestrator stand-in with per-source clients.
|
||||
|
||||
``search()`` is the default orchestrator path (single-source mode or
|
||||
first hybrid source). ``client(name)`` returns the per-source client
|
||||
so a source-targeted basic search can call ``.search()`` directly on
|
||||
the specific source rather than going through the chain.
|
||||
"""
|
||||
|
||||
def __init__(self, default_results, per_source_results, fail_unknown=False):
|
||||
self._default = default_results
|
||||
self._sources = per_source_results
|
||||
self._fail_unknown = fail_unknown
|
||||
self.default_search_calls = 0
|
||||
self.per_source_calls = {}
|
||||
|
||||
async def search(self, query, timeout=None, progress_callback=None, exclude_sources=None):
|
||||
self.default_search_calls += 1
|
||||
return self._default
|
||||
|
||||
def client(self, name):
|
||||
if name not in self._sources:
|
||||
if self._fail_unknown:
|
||||
return None
|
||||
return None
|
||||
plugin = _FakeSoulseek(tracks=self._sources[name][0], albums=self._sources[name][1])
|
||||
# Record which sources got asked for.
|
||||
self.per_source_calls.setdefault(name, 0)
|
||||
self.per_source_calls[name] += 1
|
||||
return plugin
|
||||
|
||||
|
||||
def test_source_param_routes_to_specific_client():
|
||||
"""``source='tidal'`` calls the Tidal client directly, NOT the
|
||||
orchestrator's chain. Ensures the per-source basic search bypasses
|
||||
the hybrid-first selection so users can target any active source."""
|
||||
tidal_track = _SearchTrack('From Tidal', 0.9, username='tidal')
|
||||
soul_track = _SearchTrack('From Soulseek', 0.5, username='peer')
|
||||
|
||||
orch = _FakeOrchestratorMulti(
|
||||
default_results=([soul_track], []),
|
||||
per_source_results={
|
||||
'tidal': ([tidal_track], []),
|
||||
'soulseek': ([soul_track], []),
|
||||
},
|
||||
)
|
||||
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
|
||||
|
||||
# Tidal result returned, NOT soulseek result.
|
||||
assert len(result) == 1
|
||||
assert result[0]['name'] == 'From Tidal'
|
||||
# Orchestrator default chain NOT consulted.
|
||||
assert orch.default_search_calls == 0
|
||||
# Tidal client was called exactly once.
|
||||
assert orch.per_source_calls.get('tidal') == 1
|
||||
|
||||
|
||||
def test_no_source_param_falls_through_to_orchestrator_default():
|
||||
"""When ``source`` is omitted, behaviour is identical to pre-redesign:
|
||||
orchestrator.search() is called and routes to the configured source
|
||||
(single-source mode) or first hybrid source. Preserves the existing
|
||||
contract for callers that don't opt in to per-source targeting."""
|
||||
track = _SearchTrack('Default', 0.7)
|
||||
orch = _FakeOrchestratorMulti(
|
||||
default_results=([track], []),
|
||||
per_source_results={'tidal': ([], [])},
|
||||
)
|
||||
result = basic.run_basic_search('q', orch, _sync_run_async)
|
||||
|
||||
assert result[0]['name'] == 'Default'
|
||||
assert orch.default_search_calls == 1
|
||||
assert orch.per_source_calls == {}
|
||||
|
||||
|
||||
def test_unknown_source_falls_back_to_orchestrator():
|
||||
"""Bogus source name (e.g. user-edited config with a typo) falls
|
||||
through to the orchestrator default rather than returning an empty
|
||||
list silently. Logged as a warning so misconfiguration is visible."""
|
||||
track = _SearchTrack('Default', 0.7)
|
||||
orch = _FakeOrchestratorMulti(
|
||||
default_results=([track], []),
|
||||
per_source_results={'tidal': ([], [])},
|
||||
)
|
||||
result = basic.run_basic_search('q', orch, _sync_run_async, source='nonexistent')
|
||||
|
||||
assert result[0]['name'] == 'Default'
|
||||
assert orch.default_search_calls == 1
|
||||
|
||||
|
||||
def test_backwards_compat_alias_still_works():
|
||||
"""``run_basic_soulseek_search`` is the legacy name; any external
|
||||
caller that hasn't been updated must keep working. Aliased to
|
||||
``run_basic_search`` so both call the same code path."""
|
||||
track = _SearchTrack('Compat', 0.5)
|
||||
client = _FakeSoulseek(tracks=[track])
|
||||
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
|
||||
assert result[0]['name'] == 'Compat'
|
||||
assert basic.run_basic_soulseek_search is basic.run_basic_search
|
||||
|
||||
|
||||
def test_source_targeted_search_serialises_albums_with_tracks():
|
||||
"""Source-targeted path goes through the same normaliser as the
|
||||
default path, so albums returned via a specific source still get
|
||||
their tracks serialised + ``result_type='album'`` tagged."""
|
||||
inner = _SearchTrack('inner', 0.5, filename='in.mp3')
|
||||
album = _SearchAlbum('TidalAlbum', 0.9, tracks=[inner], username='tidal')
|
||||
orch = _FakeOrchestratorMulti(
|
||||
default_results=([], []),
|
||||
per_source_results={'tidal': ([], [album])},
|
||||
)
|
||||
|
||||
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
|
||||
|
||||
assert result[0]['result_type'] == 'album'
|
||||
assert result[0]['tracks'][0]['name'] == 'inner'
|
||||
|
|
|
|||
|
|
@ -5536,19 +5536,50 @@ def _build_search_deps():
|
|||
)
|
||||
|
||||
|
||||
@app.route('/api/search/sources', methods=['GET'])
|
||||
def search_sources():
|
||||
"""Return the list of active download sources available for basic search.
|
||||
|
||||
In single-source mode returns that one source. In hybrid mode returns
|
||||
every source in the configured chain so the frontend can render a
|
||||
source-picker chip row and let the user search specific sources.
|
||||
|
||||
Response shape: ``{"mode": "hybrid"|<source>, "sources": [{"name": str,
|
||||
"display_name": str}]}``
|
||||
"""
|
||||
if not download_orchestrator:
|
||||
return jsonify({"mode": "soulseek", "sources": [{"name": "soulseek", "display_name": "Soulseek"}]})
|
||||
mode = download_orchestrator.mode
|
||||
if mode == 'hybrid':
|
||||
chain = download_orchestrator._resolve_source_chain()
|
||||
sources = [
|
||||
{"name": s, "display_name": download_orchestrator.registry.display_name(s)}
|
||||
for s in chain
|
||||
]
|
||||
else:
|
||||
sources = [{"name": mode, "display_name": download_orchestrator.registry.display_name(mode)}]
|
||||
return jsonify({"mode": mode, "sources": sources})
|
||||
|
||||
|
||||
@app.route('/api/search', methods=['POST'])
|
||||
def search_music():
|
||||
"""Basic Soulseek file search."""
|
||||
"""Basic download-source file search.
|
||||
|
||||
Accepts an optional ``source`` body param to target a specific source
|
||||
in hybrid mode (e.g. ``"soulseek"``, ``"tidal"``). When omitted, uses
|
||||
the active source (single-source mode) or the first hybrid source.
|
||||
"""
|
||||
data = request.get_json()
|
||||
query = data.get('query')
|
||||
if not query:
|
||||
return jsonify({"error": "No search query provided."}), 400
|
||||
|
||||
logger.info(f"Web UI Search initiated for: '{query}'")
|
||||
requested_source = (data.get('source') or '').strip().lower() or None
|
||||
logger.info(f"Web UI Search initiated for: '{query}'" + (f" (source={requested_source})" if requested_source else ""))
|
||||
add_activity_item("", "Search Started", f"'{query}'", "Now")
|
||||
|
||||
try:
|
||||
results = _search_basic.run_basic_soulseek_search(query, download_orchestrator, run_async)
|
||||
results = _search_basic.run_basic_search(query, download_orchestrator, run_async, source=requested_source)
|
||||
add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now")
|
||||
return jsonify({"results": results})
|
||||
except Exception as e:
|
||||
|
|
|
|||
143
webui/index.html
143
webui/index.html
|
|
@ -10,6 +10,7 @@
|
|||
<meta name="theme-color" content="#1db954">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
|
||||
{{ vite_assets('head')|safe }}
|
||||
|
|
@ -2088,103 +2089,71 @@
|
|||
results are cached per (query, source) pair. -->
|
||||
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
|
||||
|
||||
<!-- Basic Search Section (Current) -->
|
||||
<!-- Basic Search Section -->
|
||||
<div id="basic-search-section" class="search-section">
|
||||
<!-- Search Bar: Replicates create_elegant_search_bar() -->
|
||||
<div class="search-bar-container">
|
||||
<input type="text" id="downloads-search-input"
|
||||
placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
|
||||
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button>
|
||||
<button id="downloads-search-btn">🔍 Search</button>
|
||||
|
||||
<!-- Source picker: chip row, one chip per active download source.
|
||||
Populated by downloads.js:initBasicSearchSources().
|
||||
Hidden until sources load; in single-source mode shows one
|
||||
non-interactive chip so the user knows what they're searching. -->
|
||||
<div class="bs-source-row" id="bs-source-row" aria-label="Search source" role="tablist"></div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="bs-search-bar">
|
||||
<div class="bs-search-input-wrap">
|
||||
<svg class="bs-search-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="M15 15l3 3"/></svg>
|
||||
<input type="text" id="downloads-search-input"
|
||||
placeholder="Search artists, albums, tracks…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button id="downloads-cancel-btn" class="bs-cancel-btn hidden" aria-label="Cancel">✕</button>
|
||||
</div>
|
||||
<button id="downloads-search-btn" class="bs-search-btn">Search</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="filters-container" class="filters-container hidden">
|
||||
<div class="filter-toggle-header">
|
||||
<button id="filter-toggle-btn" class="filter-toggle-btn">⏷ Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="filter-content" class="filter-content hidden">
|
||||
<!-- Filter by Type -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Type:</label>
|
||||
<button class="filter-btn active" data-filter-type="type"
|
||||
data-value="all">All</button>
|
||||
<button class="filter-btn" data-filter-type="type"
|
||||
data-value="album">Albums</button>
|
||||
<button class="filter-btn" data-filter-type="type"
|
||||
data-value="track">Singles</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter by Format -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Format:</label>
|
||||
<button class="filter-btn active" data-filter-type="format"
|
||||
data-value="all">All</button>
|
||||
<button class="filter-btn" data-filter-type="format"
|
||||
data-value="flac">FLAC</button>
|
||||
<button class="filter-btn" data-filter-type="format"
|
||||
data-value="mp3">MP3</button>
|
||||
<!-- Added missing format buttons -->
|
||||
<button class="filter-btn" data-filter-type="format"
|
||||
data-value="ogg">OGG</button>
|
||||
<button class="filter-btn" data-filter-type="format"
|
||||
data-value="aac">AAC</button>
|
||||
<button class="filter-btn" data-filter-type="format"
|
||||
data-value="wma">WMA</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort Controls -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Sort by:</label>
|
||||
<button id="sort-order-btn" class="filter-btn sort-order-btn"
|
||||
data-order="desc">↓</button>
|
||||
<!-- Added all sort options from the GUI -->
|
||||
<button class="filter-btn active" data-filter-type="sort"
|
||||
data-value="relevance">Relevance</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="quality_score">Quality</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="size">Size</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="title">Name</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="username">Uploader</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="bitrate">Bitrate</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="duration">Duration</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="availability">Available</button>
|
||||
<button class="filter-btn" data-filter-type="sort"
|
||||
data-value="upload_speed">Speed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search Status Bar -->
|
||||
<div class="search-status-container">
|
||||
<!-- Status / loading bar -->
|
||||
<div class="bs-status-bar">
|
||||
<div class="spinner-animation hidden"></div>
|
||||
<p id="search-status-text">Ready to search • Enter artist, song, or album name</p>
|
||||
<span id="search-status-text" class="bs-status-text">Enter an artist, album, or track name to search</span>
|
||||
<div class="dots-animation hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results Area: Replicates the QScrollArea -->
|
||||
<div class="search-results-container">
|
||||
<div class="search-results-header">
|
||||
<h3>Search Results</h3>
|
||||
<!-- Filters: always-visible compact pill row -->
|
||||
<div id="filters-container" class="bs-filters hidden">
|
||||
<div class="bs-filter-group">
|
||||
<span class="bs-filter-label">Type</span>
|
||||
<button class="filter-btn bs-filter-pill active" data-filter-type="type" data-value="all">All</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="album">Albums</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="track">Tracks</button>
|
||||
</div>
|
||||
<div class="search-results-scroll-area" id="search-results-area">
|
||||
<!--
|
||||
The placeholder search results have been removed.
|
||||
This area will now be populated by JavaScript based on API responses.
|
||||
-->
|
||||
<div class="search-results-placeholder">
|
||||
<p>Your search results will appear here.</p>
|
||||
</div>
|
||||
<div class="bs-filter-group">
|
||||
<span class="bs-filter-label">Format</span>
|
||||
<button class="filter-btn bs-filter-pill active" data-filter-type="format" data-value="all">All</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="flac">FLAC</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="mp3">MP3</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="ogg">OGG</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="aac">AAC</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="wma">WMA</button>
|
||||
</div>
|
||||
<div class="bs-filter-group">
|
||||
<span class="bs-filter-label">Sort</span>
|
||||
<button id="sort-order-btn" class="filter-btn bs-filter-pill sort-order-btn" data-order="desc">↓</button>
|
||||
<button class="filter-btn bs-filter-pill active" data-filter-type="sort" data-value="relevance">Relevance</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="quality_score">Quality</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="size">Size</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="title">Name</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="username">Uploader</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="bitrate">Bitrate</button>
|
||||
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="duration">Duration</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results area -->
|
||||
<div class="bs-results-wrap" id="search-results-area">
|
||||
<div class="search-results-placeholder">
|
||||
<p>Enter a search term to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- End Basic Search Section -->
|
||||
|
||||
|
|
|
|||
694
webui/static/basic-search-v2.css
Normal file
694
webui/static/basic-search-v2.css
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
/* =====================================================================
|
||||
* Basic Search — visual redesign (functional behaviour untouched)
|
||||
*
|
||||
* Self-contained sheet appended via index.html link. Targets the new
|
||||
* markup (``bs-*`` prefixed classes). Existing
|
||||
* ``.search-bar-container`` / ``.filter-btn`` / ``.search-results-container``
|
||||
* rules earlier in style.css still apply elsewhere (other search UIs reuse
|
||||
* them); they no longer match the new basic-search markup so this redesign
|
||||
* is fully scoped.
|
||||
* ===================================================================== */
|
||||
|
||||
|
||||
/* — Source chip row above the search bar. One chip per active hybrid
|
||||
* source; in single-source mode the only chip is rendered with the
|
||||
* ``.single`` modifier so it reads as a label rather than a control. */
|
||||
.bs-source-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.bs-source-row:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bs-source-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(255, 255, 255, 0.035) 0%,
|
||||
rgba(0, 0, 0, 0.2) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bs-source-chip:hover:not(.active):not(.single):not(:disabled) {
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(var(--accent-rgb), 0.08) 0%,
|
||||
rgba(0, 0, 0, 0.2) 100%);
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bs-source-chip.active {
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
|
||||
border-color: rgba(var(--accent-rgb), 0.55);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.bs-source-chip.single {
|
||||
cursor: default;
|
||||
border-style: dashed;
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
background: transparent;
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
|
||||
/* — Search bar: glass card matching the dashboard / auto-sync vibe. */
|
||||
.bs-search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background:
|
||||
radial-gradient(ellipse at 0% 0%,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 6%, transparent) 0%,
|
||||
transparent 60%),
|
||||
linear-gradient(160deg,
|
||||
rgba(22, 25, 36, 0.7) 0%,
|
||||
rgba(14, 16, 24, 0.85) 100%);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 6px 20px rgba(0, 0, 0, 0.25);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bs-search-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
height: 42px;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.bs-search-input-wrap:focus-within {
|
||||
border-color: rgba(var(--accent-rgb), 0.45);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
|
||||
}
|
||||
|
||||
.bs-search-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bs-search-input-wrap:focus-within .bs-search-icon {
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
#basic-search-section #downloads-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#basic-search-section #downloads-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.bs-cancel-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bs-cancel-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bs-search-btn {
|
||||
padding: 0 22px;
|
||||
height: 42px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.55);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 28%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.bs-search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 6px 18px color-mix(in srgb, rgb(var(--accent-rgb)) 40%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.bs-search-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
||||
/* — Status bar: thin accent-tinted pill matching the dashboard vibe. */
|
||||
.bs-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 4%, transparent),
|
||||
transparent);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bs-status-text {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
/* — Filters: compact pill row, always visible after first search. */
|
||||
.bs-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
padding: 12px 14px;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(255, 255, 255, 0.025) 0%,
|
||||
rgba(0, 0, 0, 0.15) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bs-filter-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bs-filter-label {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.bs-filter-pill {
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 99px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.bs-filter-pill:hover:not(.active) {
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bs-filter-pill.active {
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 80%, white 20%),
|
||||
rgb(var(--accent-rgb)));
|
||||
border-color: rgba(var(--accent-rgb), 0.55);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
|
||||
/* — Results area: glass surface for cards. */
|
||||
.bs-results-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bs-results-wrap .search-results-placeholder {
|
||||
text-align: center;
|
||||
padding: 60px 30px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
/* — Album result card: glass + accent left-edge stripe + cover icon.
|
||||
* ``!important`` is used liberally below because the original
|
||||
* ``.album-result-card`` / ``.album-icon`` / etc. rules in style.css
|
||||
* (~7247 onwards) are unscoped and apply heavyweight styles (24px
|
||||
* padding, 56px icon, 24px border-radius, big box-shadows) that
|
||||
* collide with this redesign. Scoping with ``#basic-search-section``
|
||||
* wins on specificity for some properties but the original
|
||||
* ``box-shadow`` and ``padding`` rules need explicit override to
|
||||
* defeat the cascade interaction with hover / pseudo states. */
|
||||
#basic-search-section .album-result-card {
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
rgba(0, 0, 0, 0.2) 100%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||
border-radius: 14px !important;
|
||||
position: relative;
|
||||
transition: border-color 0.2s, transform 0.2s !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 2px 10px rgba(0, 0, 0, 0.2) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-result-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg,
|
||||
rgb(var(--accent-light-rgb)),
|
||||
rgb(var(--accent-rgb)));
|
||||
border-radius: 0 3px 3px 0;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, rgb(var(--accent-rgb)) 50%, transparent);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#basic-search-section .album-result-card:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.35) !important;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(var(--accent-rgb), 0.06) 0%,
|
||||
rgba(0, 0, 0, 0.2) 100%) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 6px 20px rgba(0, 0, 0, 0.3) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-card-header {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 16px !important;
|
||||
padding: 18px 22px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#basic-search-section .album-expand-indicator {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
transition: transform 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#basic-search-section .album-result-card.expanded .album-expand-indicator {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
#basic-search-section .album-icon {
|
||||
width: 52px !important;
|
||||
height: 52px !important;
|
||||
min-width: 52px;
|
||||
border-radius: 10px !important;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent),
|
||||
rgba(0, 0, 0, 0.4)) !important;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
font-size: 26px !important;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||
color: rgba(var(--accent-rgb), 1.0);
|
||||
}
|
||||
|
||||
#basic-search-section .album-info {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#basic-search-section .album-title {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 0 4px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-artist {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 6px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-details {
|
||||
color: rgb(var(--accent-light-rgb)) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 3px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-uploader {
|
||||
color: rgba(255, 255, 255, 0.4) !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.3;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .album-actions {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 6px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* — Track result card: glass row, similar height to album cards. */
|
||||
#basic-search-section .track-result-card {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 16px !important;
|
||||
padding: 16px 22px !important;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(255, 255, 255, 0.03) 0%,
|
||||
rgba(0, 0, 0, 0.18) 100%) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
border-radius: 14px !important;
|
||||
position: relative;
|
||||
transition: border-color 0.2s, transform 0.2s, background 0.2s !important;
|
||||
margin: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 2px 10px rgba(0, 0, 0, 0.2) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-result-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg,
|
||||
rgba(var(--accent-rgb), 0.7),
|
||||
rgba(var(--accent-rgb), 0.3));
|
||||
border-radius: 0 2px 2px 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#basic-search-section .track-result-card:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.3) !important;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(var(--accent-rgb), 0.06) 0%,
|
||||
rgba(0, 0, 0, 0.18) 100%) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-icon {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
min-width: 44px;
|
||||
border-radius: 10px !important;
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
font-size: 22px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#basic-search-section .track-info {
|
||||
flex: 1 1 auto !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#basic-search-section .track-title {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 0 3px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-artist {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 5px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-details {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 3px 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-uploader {
|
||||
color: rgba(255, 255, 255, 0.38) !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.3;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#basic-search-section .track-actions {
|
||||
display: flex !important;
|
||||
gap: 6px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* — Action buttons inside album / track cards: pill primary + ghost. */
|
||||
#basic-search-section .album-download-btn,
|
||||
#basic-search-section .album-matched-btn,
|
||||
#basic-search-section .track-download-btn,
|
||||
#basic-search-section .track-matched-btn,
|
||||
#basic-search-section .track-stream-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#basic-search-section .album-download-btn,
|
||||
#basic-search-section .track-download-btn {
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, rgb(var(--accent-rgb)) 92%, white 8%),
|
||||
rgb(var(--accent-rgb)));
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.5);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 22%, transparent);
|
||||
}
|
||||
|
||||
#basic-search-section .album-download-btn:hover,
|
||||
#basic-search-section .track-download-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent);
|
||||
}
|
||||
|
||||
#basic-search-section .album-matched-btn,
|
||||
#basic-search-section .track-matched-btn {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
}
|
||||
|
||||
#basic-search-section .album-matched-btn:hover,
|
||||
#basic-search-section .track-matched-btn:hover {
|
||||
background: color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
|
||||
border-color: rgba(var(--accent-rgb), 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#basic-search-section .track-stream-btn {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
#basic-search-section .track-stream-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* — Expanded album track list */
|
||||
#basic-search-section .album-track-list {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 18px 10px 50px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
#basic-search-section .track-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item-title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item-details {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#basic-search-section .track-item-actions .track-stream-btn,
|
||||
#basic-search-section .track-item-actions .track-download-btn,
|
||||
#basic-search-section .track-item-actions .track-matched-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
#basic-search-section .disc-separator {
|
||||
padding: 8px 18px 6px 50px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 10px !important;
|
||||
color: rgb(var(--accent-light-rgb)) !important;
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.2) !important;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
background: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
/* — Responsive: collapse action button row on narrow viewports. */
|
||||
@media (max-width: 900px) {
|
||||
#basic-search-section .album-actions,
|
||||
#basic-search-section .track-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.bs-filters {
|
||||
gap: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -4772,7 +4772,69 @@ function updateModalSyncProgress(playlistId, progress) {
|
|||
}
|
||||
|
||||
|
||||
// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option).
|
||||
// ── Basic-search source picker ──────────────────────────────────────────────
|
||||
// Tracks which download source the user has selected in the chip row. Null
|
||||
// means "use the orchestrator's default" (same as pre-redesign behaviour).
|
||||
let _bsActiveSource = null;
|
||||
|
||||
async function initBasicSearchSources() {
|
||||
const row = document.getElementById('bs-source-row');
|
||||
if (!row) return;
|
||||
try {
|
||||
const resp = await fetch('/api/search/sources');
|
||||
if (!resp.ok) return;
|
||||
const { mode, sources } = await resp.json();
|
||||
if (!sources || !sources.length) return;
|
||||
|
||||
row.innerHTML = '';
|
||||
const isSingle = mode !== 'hybrid' || sources.length < 2;
|
||||
|
||||
sources.forEach((src, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bs-source-chip' + (i === 0 ? ' active' : '');
|
||||
btn.dataset.source = src.name;
|
||||
btn.setAttribute('role', 'tab');
|
||||
btn.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
||||
btn.setAttribute('title', src.display_name);
|
||||
btn.innerHTML = `<span class="bs-source-label">${_escBsHtml(src.display_name)}</span>`;
|
||||
|
||||
if (isSingle) {
|
||||
// Non-interactive: single source, just a label.
|
||||
btn.classList.add('single');
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
btn.addEventListener('click', () => {
|
||||
row.querySelectorAll('.bs-source-chip').forEach(c => {
|
||||
c.classList.remove('active');
|
||||
c.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
_bsActiveSource = src.name;
|
||||
// Re-run last search with new source if results already showing.
|
||||
const query = document.getElementById('downloads-search-input')?.value?.trim();
|
||||
if (query && window.currentSearchResults?.length) {
|
||||
performDownloadsSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
row.appendChild(btn);
|
||||
});
|
||||
|
||||
// Default active source = first in chain.
|
||||
_bsActiveSource = isSingle ? null : sources[0]?.name ?? null;
|
||||
} catch (_err) {
|
||||
// Non-fatal — search still works without the picker.
|
||||
}
|
||||
}
|
||||
|
||||
function _escBsHtml(str) {
|
||||
return String(str || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
|
||||
// Raw download-source file search (basic search tab).
|
||||
async function performDownloadsSearch() {
|
||||
const query = document.getElementById('downloads-search-input').value.trim();
|
||||
if (!query) {
|
||||
|
|
@ -4802,10 +4864,15 @@ async function performDownloadsSearch() {
|
|||
displayDownloadsResults([]); // Clear previous results
|
||||
|
||||
// --- 2. Perform the Fetch Request ---
|
||||
// Source param routes the search to a specific download source in
|
||||
// hybrid mode. Omitted in single-source mode (backend falls through
|
||||
// to orchestrator.search() which targets the configured source).
|
||||
const body = { query };
|
||||
if (_bsActiveSource) body.source = _bsActiveSource;
|
||||
const response = await fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
body: JSON.stringify(body),
|
||||
signal: searchAbortController.signal // Link fetch to the AbortController
|
||||
});
|
||||
|
||||
|
|
@ -4825,7 +4892,8 @@ async function performDownloadsSearch() {
|
|||
statusText.textContent = `No results found for '${query}'`;
|
||||
showToast('No results found', 'error');
|
||||
} else {
|
||||
document.getElementById('filters-container').classList.remove('hidden');
|
||||
const filtersEl = document.getElementById('filters-container');
|
||||
if (filtersEl) filtersEl.classList.remove('hidden');
|
||||
|
||||
// Count albums and singles like the GUI app
|
||||
let totalAlbums = 0;
|
||||
|
|
@ -5658,6 +5726,15 @@ let _gsController = null;
|
|||
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
|
||||
})();
|
||||
|
||||
// Init basic-search source chip row once the DOM is ready.
|
||||
(function _bsInit() {
|
||||
const run = () => {
|
||||
if (typeof initBasicSearchSources === 'function') initBasicSearchSources();
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
function _gsUpdateVisibility() {
|
||||
const bar = document.getElementById('gsearch-bar');
|
||||
const aura = document.getElementById('gsearch-aura');
|
||||
|
|
|
|||
|
|
@ -3415,6 +3415,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.6.5': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Basic search: visual overhaul + per-source picker in hybrid mode', desc: 'the basic search tab on the Search page was carrying its original visual treatment from before the rest of the app moved toward the glassy / accent-radial aesthetic, and it always targeted the first source in the hybrid chain with no way to pick a different one. Two things in this commit: (1) full visual redesign — glass search-bar card with accent radial wash + focus ring, pill primary search button, always-visible compact filter pill row (Type / Format / Sort), accent-tinted status pill, album result cards with accent left-edge stripe + cover icon + chevron expand + pill action buttons, track result cards as slim glass rows, multi-disc separators in album track lists, responsive button-stack on narrow viewports. Self-contained sheet at ``webui/static/basic-search-v2.css`` so revert is just dropping the link tag. (2) source picker — small chip row above the search bar lists every active source in the hybrid chain. Click a chip to target that specific source for the next search. In single-source mode the chip is rendered as a dashed-border label so the user always knows what they\'re searching. New ``GET /api/search/sources`` endpoint returns the active source list; ``/api/search`` accepts an optional ``source`` body param to target that specific source via its client. Backwards compatible — omitting ``source`` uses the orchestrator default exactly as before. Pinned with 5 new unit tests (source routing, fallback on unknown source, no-source default, alias preservation, album serialisation through the source-targeted path) on top of the existing 6 tests.', page: 'search' },
|
||||
{ title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' },
|
||||
],
|
||||
'2.6.4': [
|
||||
|
|
|
|||
|
|
@ -7155,7 +7155,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
align-items: center;
|
||||
gap: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Neumorphic depth shadows - elevated card effect */
|
||||
box-shadow:
|
||||
|
|
@ -7255,7 +7254,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
margin: 12px 8px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* Neumorphic depth shadows - elevated card effect */
|
||||
box-shadow:
|
||||
|
|
|
|||
Loading…
Reference in a new issue