video search: stream slskd results (start + poll) — fixes 'no results'
The old search did a 4.5s slskd search + 8s wait; slskd responses trickle in over 10-30s, so the window closed before results arrived (you'd see them in slskd but the panel said none). Now it works like the music side: - slskd_search.start_search() (uses the shared soulseek.search_timeout) + poll_responses(). - POST /downloads/search/start (mock = immediate; soulseek = returns a search id) + GET /downloads/search/poll. Shared _evaluate_hits ranks each poll's hits. - UI streams: starts the search, polls every 1.3s, renders results live with a 'searching…' badge, stops when results plateau (4 stable polls) or ~32s. Live re-renders suppress per-card entrance so it doesn't blink. Tests green, ruff clean.
This commit is contained in:
parent
cb56ee2bee
commit
42c67a6d4d
4 changed files with 212 additions and 55 deletions
|
|
@ -54,6 +54,45 @@ _SLSKD_KEYS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_hits(raw, profile, scope, want_season, want_episode) -> list:
|
||||||
|
"""Parse → evaluate → rank a list of raw indexer hits against the quality profile.
|
||||||
|
Shared by the mock search and the live slskd start/poll endpoints."""
|
||||||
|
from core.video.quality_eval import evaluate_release
|
||||||
|
from core.video.release_parse import parse_release
|
||||||
|
results = []
|
||||||
|
for hit in raw:
|
||||||
|
parsed = parse_release(hit.get("title"))
|
||||||
|
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
|
||||||
|
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
|
||||||
|
want_episode=want_episode, size_gb=size_gb)
|
||||||
|
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
|
||||||
|
results.append({
|
||||||
|
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
|
||||||
|
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
|
||||||
|
"username": hit.get("username"), "slots": hit.get("slots"),
|
||||||
|
"filename": hit.get("filename"), "_avail": avail,
|
||||||
|
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
|
||||||
|
"rejected": verdict["rejected"], "score": verdict["score"],
|
||||||
|
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
|
||||||
|
"codec": parsed.get("codec"), "hdr": parsed.get("hdr"),
|
||||||
|
"audio": parsed.get("audio"), "group": parsed.get("group"),
|
||||||
|
"repack": parsed.get("repack") or parsed.get("proper"),
|
||||||
|
})
|
||||||
|
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True)
|
||||||
|
for r in results:
|
||||||
|
r.pop("_avail", None)
|
||||||
|
return results[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def _search_ints(body):
|
||||||
|
def _int(v):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return _int(body.get("season")), _int(body.get("episode")), _int(body.get("season_end"))
|
||||||
|
|
||||||
|
|
||||||
def register_routes(bp):
|
def register_routes(bp):
|
||||||
@bp.route("/downloads/config", methods=["GET"])
|
@bp.route("/downloads/config", methods=["GET"])
|
||||||
def video_downloads_config():
|
def video_downloads_config():
|
||||||
|
|
@ -117,28 +156,16 @@ def register_routes(bp):
|
||||||
Body: {scope, title, year?, season?, episode?, season_end?}."""
|
Body: {scope, title, year?, season?, episode?, season_end?}."""
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
from core.video.mock_search import mock_search
|
from core.video.mock_search import mock_search
|
||||||
from core.video.quality_eval import evaluate_release
|
|
||||||
from core.video.quality_profile import load as load_profile
|
from core.video.quality_profile import load as load_profile
|
||||||
from core.video.release_parse import parse_release
|
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
scope = str(body.get("scope") or "movie").lower()
|
scope = str(body.get("scope") or "movie").lower()
|
||||||
title = body.get("title") or ""
|
title = body.get("title") or ""
|
||||||
source = str(body.get("source") or "").lower()
|
source = str(body.get("source") or "").lower()
|
||||||
|
want_season, want_episode, season_end = _search_ints(body)
|
||||||
def _int(v):
|
|
||||||
try:
|
|
||||||
return int(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
want_season, want_episode = _int(body.get("season")), _int(body.get("episode"))
|
|
||||||
profile = load_profile(get_video_db())
|
profile = load_profile(get_video_db())
|
||||||
|
|
||||||
live = False
|
live = False
|
||||||
if source == "soulseek":
|
if source == "soulseek":
|
||||||
# REAL Soulseek search (slskd). Torrent/Usenet stay mocked until those
|
|
||||||
# indexers are wired. Same {title, size_bytes, …} shape downstream.
|
|
||||||
from core.video.slskd_search import build_query, slskd_search
|
from core.video.slskd_search import build_query, slskd_search
|
||||||
sres = slskd_search(build_query(scope, title, year=body.get("year"),
|
sres = slskd_search(build_query(scope, title, year=body.get("year"),
|
||||||
season=want_season, episode=want_episode))
|
season=want_season, episode=want_episode))
|
||||||
|
|
@ -149,33 +176,56 @@ def register_routes(bp):
|
||||||
raw, live = sres["hits"], True
|
raw, live = sres["hits"], True
|
||||||
else:
|
else:
|
||||||
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
|
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
|
||||||
episode=want_episode, season_end=_int(body.get("season_end")),
|
episode=want_episode, season_end=season_end, source=source)
|
||||||
source=source)
|
return jsonify({"scope": scope, "live": live,
|
||||||
|
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
|
||||||
|
|
||||||
results = []
|
@bp.route("/downloads/search/start", methods=["POST"])
|
||||||
for hit in raw:
|
def video_downloads_search_start():
|
||||||
parsed = parse_release(hit.get("title"))
|
"""Begin a search. For mock sources the results come back immediately; for
|
||||||
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
|
Soulseek it returns a slskd search id to poll (results trickle in over ~30s,
|
||||||
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
|
like the music side) — fixes 'no results' from waiting too briefly."""
|
||||||
want_episode=want_episode, size_gb=size_gb)
|
from . import get_video_db
|
||||||
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
|
from core.video.mock_search import mock_search
|
||||||
results.append({
|
from core.video.quality_profile import load as load_profile
|
||||||
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
|
body = request.get_json(silent=True) or {}
|
||||||
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
|
scope = str(body.get("scope") or "movie").lower()
|
||||||
"username": hit.get("username"), "slots": hit.get("slots"),
|
title = body.get("title") or ""
|
||||||
"filename": hit.get("filename"), "_avail": avail,
|
source = str(body.get("source") or "").lower()
|
||||||
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
|
want_season, want_episode, season_end = _search_ints(body)
|
||||||
"rejected": verdict["rejected"], "score": verdict["score"],
|
|
||||||
"resolution": parsed.get("resolution"), "source": parsed.get("source"),
|
if source == "soulseek":
|
||||||
"codec": parsed.get("codec"), "hdr": parsed.get("hdr"),
|
from core.video.slskd_search import build_query, start_search
|
||||||
"audio": parsed.get("audio"), "group": parsed.get("group"),
|
res = start_search(build_query(scope, title, year=body.get("year"),
|
||||||
"repack": parsed.get("repack") or parsed.get("proper"),
|
season=want_season, episode=want_episode))
|
||||||
})
|
if not res.get("configured"):
|
||||||
# accepted first, then best score, then most availability (seeders/peers).
|
return jsonify({"error": "slskd isn't configured — set its URL on Settings → Downloads."})
|
||||||
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True)
|
if res.get("error"):
|
||||||
for r in results:
|
return jsonify({"error": "slskd: " + str(res["error"])})
|
||||||
r.pop("_avail", None)
|
return jsonify({"id": res["id"], "live": True, "complete": False})
|
||||||
return jsonify({"scope": scope, "results": results[:40], "live": live})
|
# mock sources resolve in one shot
|
||||||
|
profile = load_profile(get_video_db())
|
||||||
|
raw = mock_search(scope, title, year=body.get("year"), season=want_season,
|
||||||
|
episode=want_episode, season_end=season_end, source=source)
|
||||||
|
return jsonify({"id": None, "live": False, "complete": True,
|
||||||
|
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
|
||||||
|
|
||||||
|
@bp.route("/downloads/search/poll", methods=["GET"])
|
||||||
|
def video_downloads_search_poll():
|
||||||
|
"""Current ranked results for an in-flight slskd search. Query: id, scope,
|
||||||
|
title, season?, episode?. The client polls until it stops growing or times out."""
|
||||||
|
from . import get_video_db
|
||||||
|
from core.video.quality_profile import load as load_profile
|
||||||
|
from core.video.slskd_search import poll_responses
|
||||||
|
sid = request.args.get("id")
|
||||||
|
scope = str(request.args.get("scope") or "movie").lower()
|
||||||
|
want_season, want_episode, _ = _search_ints(request.args)
|
||||||
|
if not sid:
|
||||||
|
return jsonify({"results": [], "live": True})
|
||||||
|
profile = load_profile(get_video_db())
|
||||||
|
raw = poll_responses(sid)
|
||||||
|
return jsonify({"live": True,
|
||||||
|
"results": _evaluate_hits(raw, profile, scope, want_season, want_episode)})
|
||||||
|
|
||||||
@bp.route("/downloads/grab", methods=["POST"])
|
@bp.route("/downloads/grab", methods=["POST"])
|
||||||
def video_downloads_grab():
|
def video_downloads_grab():
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ VIDEO_EXTS = frozenset((
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
from config.settings import config_manager
|
||||||
|
base = str(config_manager.get("soulseek.slskd_url", "") or "").rstrip("/")
|
||||||
|
key = config_manager.get("soulseek.api_key", "") or ""
|
||||||
|
return base, ({"X-API-Key": key} if key else {})
|
||||||
|
|
||||||
|
|
||||||
def build_query(scope: str, title: Any, *, year: Any = None, season: Any = None,
|
def build_query(scope: str, title: Any, *, year: Any = None, season: Any = None,
|
||||||
episode: Any = None) -> str:
|
episode: Any = None) -> str:
|
||||||
"""The text we hand slskd for a given scope (movie / episode / season / series)."""
|
"""The text we hand slskd for a given scope (movie / episode / season / series)."""
|
||||||
|
|
@ -100,6 +107,61 @@ def group_video_files(responses: Any) -> list:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _min_speed_bytes() -> int:
|
||||||
|
from config.settings import config_manager
|
||||||
|
try:
|
||||||
|
return int(config_manager.get("soulseek.min_peer_upload_speed", 0) or 0) * 125000
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def search_timeout_ms() -> int:
|
||||||
|
"""How long to ask slskd to keep searching — the SAME ``soulseek.search_timeout``
|
||||||
|
the music side uses (default 60s), so results have time to arrive."""
|
||||||
|
from config.settings import config_manager
|
||||||
|
try:
|
||||||
|
secs = int(config_manager.get("soulseek.search_timeout", 60) or 60)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
secs = 60
|
||||||
|
return max(10, min(120, secs)) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def start_search(query: str) -> dict:
|
||||||
|
"""Kick off a slskd search (don't wait). Returns {configured[, id][, error]}.
|
||||||
|
The caller polls ``poll_responses(id)`` until satisfied (like the music side)."""
|
||||||
|
base, headers = _conn()
|
||||||
|
if not base:
|
||||||
|
return {"configured": False}
|
||||||
|
payload = {"searchText": query, "timeout": search_timeout_ms(), "filterResponses": True,
|
||||||
|
"minimumResponseFileCount": 1, "minimumPeerUploadSpeed": _min_speed_bytes()}
|
||||||
|
try:
|
||||||
|
r = requests.post(base + "/api/v0/searches", json=payload, headers=headers, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
except Exception as e: # noqa: BLE001 - surface any slskd/network failure to the UI
|
||||||
|
return {"configured": True, "error": str(e)}
|
||||||
|
sid = data.get("id") if isinstance(data, dict) else (
|
||||||
|
data[0].get("id") if isinstance(data, list) and data and isinstance(data[0], dict) else None)
|
||||||
|
if not sid:
|
||||||
|
return {"configured": True, "error": "slskd returned no search id"}
|
||||||
|
return {"configured": True, "id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
def poll_responses(search_id: str) -> list:
|
||||||
|
"""Current grouped video hits for an in-flight search (cheap; call repeatedly)."""
|
||||||
|
base, headers = _conn()
|
||||||
|
if not base or not search_id:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
r = requests.get(base + "/api/v0/searches/%s/responses" % search_id, headers=headers, timeout=15)
|
||||||
|
if not r.ok:
|
||||||
|
return []
|
||||||
|
data = r.json()
|
||||||
|
except Exception: # noqa: BLE001, S110 - transient error → no new hits this poll
|
||||||
|
return []
|
||||||
|
return group_video_files(data)
|
||||||
|
|
||||||
|
|
||||||
def slskd_search(query: str, *, max_seconds: int = 8, slskd_timeout_ms: int = 4500) -> dict:
|
def slskd_search(query: str, *, max_seconds: int = 8, slskd_timeout_ms: int = 4500) -> dict:
|
||||||
"""POST a search to slskd, poll responses, return {configured, hits[, error]}.
|
"""POST a search to slskd, poll responses, return {configured, hits[, error]}.
|
||||||
Thin I/O glue around ``group_video_files``."""
|
Thin I/O glue around ``group_video_files``."""
|
||||||
|
|
|
||||||
|
|
@ -191,8 +191,6 @@
|
||||||
box.innerHTML = list.map(function (s) { return srcBlockHTML(s, false); }).join('');
|
box.innerHTML = list.map(function (s) { return srcBlockHTML(s, false); }).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scaffold: a satisfying faux-scan (animated) that resolves to "coming soon".
|
|
||||||
// No backend yet — this is the motion the real engine will drive.
|
|
||||||
// ── search + results ──────────────────────────────────────────────────────
|
// ── search + results ──────────────────────────────────────────────────────
|
||||||
var SRC_LABEL = { remux: 'Remux', bluray: 'BluRay', 'web-dl': 'WEB-DL', webrip: 'WEBRip',
|
var SRC_LABEL = { remux: 'Remux', bluray: 'BluRay', 'web-dl': 'WEB-DL', webrip: 'WEBRip',
|
||||||
hdtv: 'HDTV', dvd: 'DVD', cam: 'CAM', screener: 'Screener', workprint: 'Workprint' };
|
hdtv: 'HDTV', dvd: 'DVD', cam: 'CAM', screener: 'Screener', workprint: 'Workprint' };
|
||||||
|
|
@ -209,29 +207,71 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a search (mock indexer → real parse/evaluate/rank) and render the cards.
|
// Render the result cards (shared by the immediate mock path and live polling).
|
||||||
|
function renderResults(resultsEl, params, rows, live, done) {
|
||||||
|
rows = rows || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
resultsEl.innerHTML = done
|
||||||
|
? '<div class="vdl-res-empty">No matching releases found.</div>'
|
||||||
|
: '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
|
||||||
|
resultsEl.classList.toggle('vdl-res-noanim', !!live); // live re-renders → no per-card blink
|
||||||
|
var okN = rows.filter(function (r) { return r.accepted; }).length;
|
||||||
|
var badge = !live ? '<span class="vdl-res-demo">demo data</span>'
|
||||||
|
: done ? '<span class="vdl-res-live">● live</span>'
|
||||||
|
: '<span class="vdl-res-searching"><span class="vdl-res-spin vdl-res-spin--sm"></span>searching…</span>';
|
||||||
|
resultsEl.innerHTML =
|
||||||
|
'<div class="vdl-res-head"><strong>' + rows.length + '</strong> result' + (rows.length === 1 ? '' : 's') +
|
||||||
|
' · <span class="vdl-res-okn">' + okN + ' meet your profile</span>' + badge + '</div>' +
|
||||||
|
rows.map(resultCardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a search; for Soulseek, stream results in (poll like the music side —
|
||||||
|
// results trickle in over ~30s, so a single short wait misses them).
|
||||||
function searchInto(container, resultsEl, params, triggerRows) {
|
function searchInto(container, resultsEl, params, triggerRows) {
|
||||||
if (!resultsEl) return;
|
if (!resultsEl) return;
|
||||||
triggerRows = (triggerRows || []).filter(Boolean);
|
triggerRows = (triggerRows || []).filter(Boolean);
|
||||||
|
if (resultsEl._poll) { clearTimeout(resultsEl._poll); resultsEl._poll = null; }
|
||||||
_setScanning(triggerRows, true);
|
_setScanning(triggerRows, true);
|
||||||
resultsEl.hidden = false;
|
resultsEl.hidden = false;
|
||||||
|
resultsEl.classList.remove('vdl-res-noanim');
|
||||||
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
|
resultsEl.innerHTML = '<div class="vdl-res-loading"><span class="vdl-res-spin"></span>Searching ' + esc(scopeWord(params.scope)) + '…</div>';
|
||||||
postJSON('/api/video/downloads/search', params).then(function (d) {
|
postJSON('/api/video/downloads/search/start', params).then(function (d) {
|
||||||
_setScanning(triggerRows, false);
|
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
||||||
if (!resultsEl.isConnected) return;
|
if (d && d.error) { _setScanning(triggerRows, false); resultsEl.innerHTML = '<div class="vdl-res-empty vdl-res-err">⚠ ' + esc(d.error) + '</div>'; return; }
|
||||||
if (d && d.error) { resultsEl.innerHTML = '<div class="vdl-res-empty vdl-res-err">⚠ ' + esc(d.error) + '</div>'; return; }
|
if (!d || !d.id) { // mock / immediate
|
||||||
var rows = (d && d.results) || [];
|
_setScanning(triggerRows, false);
|
||||||
if (!rows.length) { resultsEl.innerHTML = '<div class="vdl-res-empty">No matching releases found.</div>'; return; }
|
renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true);
|
||||||
resultsEl._rows = rows; resultsEl._search = params; // for the Grab button
|
return;
|
||||||
var okN = rows.filter(function (r) { return r.accepted; }).length;
|
}
|
||||||
var live = d && d.live ? '<span class="vdl-res-live">● live</span>' : '<span class="vdl-res-demo">demo data</span>';
|
_pollSearch(resultsEl, params, d.id, triggerRows);
|
||||||
resultsEl.innerHTML =
|
|
||||||
'<div class="vdl-res-head"><strong>' + rows.length + '</strong> result' + (rows.length === 1 ? '' : 's') +
|
|
||||||
' · <span class="vdl-res-okn">' + okN + ' meet your profile</span>' + live + '</div>' +
|
|
||||||
rows.map(resultCardHTML).join('');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _pollSearch(resultsEl, params, id, triggerRows) {
|
||||||
|
var started = Date.now(), lastN = -1, stable = 0, MAX_MS = 32000;
|
||||||
|
function tick() {
|
||||||
|
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
||||||
|
var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') +
|
||||||
|
'&title=' + encodeURIComponent(params.title || '') +
|
||||||
|
(params.season != null ? '&season=' + params.season : '') +
|
||||||
|
(params.episode != null ? '&episode=' + params.episode : '');
|
||||||
|
getJSON('/api/video/downloads/search/poll' + qs).then(function (d) {
|
||||||
|
if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; }
|
||||||
|
var rows = (d && d.results) || [];
|
||||||
|
if (rows.length === lastN) { stable++; } else { stable = 0; lastN = rows.length; }
|
||||||
|
// done = timed out, OR results have plateaued for a few polls.
|
||||||
|
var done = (Date.now() - started >= MAX_MS) || (rows.length > 0 && stable >= 4);
|
||||||
|
renderResults(resultsEl, params, rows, true, done);
|
||||||
|
if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; }
|
||||||
|
else { resultsEl._poll = setTimeout(tick, 1300); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
// Grab → start a real download (Soulseek only for now), then it lives on the
|
// Grab → start a real download (Soulseek only for now), then it lives on the
|
||||||
// Downloads page. Reads the card's row + the panel's search context.
|
// Downloads page. Reads the card's row + the panel's search context.
|
||||||
function doGrab(btn) {
|
function doGrab(btn) {
|
||||||
|
|
|
||||||
|
|
@ -3258,7 +3258,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
||||||
color: rgba(255, 255, 255, 0.6); padding: 9px 2px; }
|
color: rgba(255, 255, 255, 0.6); padding: 9px 2px; }
|
||||||
.vdl-res-spin { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
|
.vdl-res-spin { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgb(var(--accent-rgb)); animation: vdlSpin 0.7s linear infinite; }
|
border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgb(var(--accent-rgb)); animation: vdlSpin 0.7s linear infinite; }
|
||||||
|
.vdl-res-spin--sm { width: 10px; height: 10px; border-width: 1.5px; }
|
||||||
@keyframes vdlSpin { to { transform: rotate(360deg); } }
|
@keyframes vdlSpin { to { transform: rotate(360deg); } }
|
||||||
|
/* while live results are still streaming in, don't replay each card's entrance */
|
||||||
|
.vdl-res-noanim .vdl-res { animation: none; }
|
||||||
|
.vdl-res-searching { margin-left: auto; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 10.5px; font-weight: 800; color: rgb(var(--accent-rgb)); }
|
||||||
.vdl-res-empty { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 9px 2px; }
|
.vdl-res-empty { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 9px 2px; }
|
||||||
.vdl-res-head { display: flex; align-items: center; gap: 8px; font-size: 11.5px; font-weight: 700;
|
.vdl-res-head { display: flex; align-items: center; gap: 8px; font-size: 11.5px; font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.5); margin: 4px 0 10px; }
|
color: rgba(255, 255, 255, 0.5); margin: 4px 0 10px; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue