From 0ff0d07c2215659d7dd57d9cb0f82cb22049f72e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 09:42:02 -0700 Subject: [PATCH] Search UX: no 'No results' flash before YouTube loads + avatar fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The big one: an empty TMDB result no longer flashes 'No results' while the (slower) YouTube channel search is still in flight — a 'YouTube channels · searching…' skeleton group (shimmer cards + spinner) shows instead, and the empty state only appears once BOTH sources resolve with nothing. Users no longer click away thinking nothing's happening. - Channel avatars: fall back to the singular 'thumbnail' field when the flat results omit the thumbnails list, so fewer cards are photoless (the rest keep the clean initials placeholder). Reduced-motion respected. --- core/video/youtube.py | 2 +- webui/static/video/video-search.js | 35 +++++++++++++++++++++++------- webui/static/video/video-side.css | 15 +++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/core/video/youtube.py b/core/video/youtube.py index 71a21082..6c0e3acf 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -280,7 +280,7 @@ def search_channels(query, limit=6, ydl_factory=None): "youtube_id": cid, "title": title, "handle": uid if str(uid or "").startswith("@") else None, - "avatar_url": _best_thumb(e.get("thumbnails")), + "avatar_url": _best_thumb(e.get("thumbnails")) or e.get("thumbnail"), "subscriber_count": e.get("channel_follower_count"), "video_count": e.get("playlist_count"), }) diff --git a/webui/static/video/video-search.js b/webui/static/video/video-search.js index 0c5fc4f3..8597d3e4 100644 --- a/webui/static/video/video-search.js +++ b/webui/static/video/video-search.js @@ -79,8 +79,23 @@ } // TMDB groups (movies/shows/people) + a YouTube channels group, each painted - // as soon as its source resolves (they're fetched in parallel). - function render(results, ytChannels) { + // as soon as its source resolves (they're fetched in parallel). While YouTube + // is still in flight a "Searching YouTube…" skeleton group shows — so an empty + // TMDB result never flashes "No results" before the channels arrive. + function ytSkeletonGroup() { + var cards = ''; + for (var i = 0; i < 4; i++) { + cards += '
' + + '' + + '' + + '
'; + } + return '

' + + 'YouTube channels' + + 'searching…

' + + '
' + cards + '
'; + } + function render(results, ytChannels, ytSearching) { var host = $('[data-video-search-results]'); if (!host) return; var html = ''; @@ -101,6 +116,8 @@ '
' + ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') + '
'; + } else if (ytSearching) { + html += ytSkeletonGroup(); } var any = html.length > 0; show('[data-video-search-hint]', false); @@ -138,14 +155,15 @@ loadTrending(); } - var curResults = null, curYt = null; // TMDB + YouTube halves of the active query + var curResults = null, curYt = null, ytSearching = false; // TMDB + YouTube halves of the active query function paint(seq) { if (seq !== reqSeq) return; - render(curResults || [], curYt || []); + render(curResults || [], curYt, ytSearching); } function runSearch(q) { var seq = ++reqSeq; curResults = null; curYt = null; + ytSearching = !!(window.VideoYoutube && q.length >= 2); show('[data-video-search-loading]', true); fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) @@ -161,11 +179,12 @@ curResults = []; paint(seq); }); - // YouTube channels in parallel (best-effort) — appended as their own group. - if (window.VideoYoutube && q.length >= 2) { + // YouTube channels in parallel (best-effort) — its own group, shown as a + // "searching…" skeleton until it resolves so nothing flashes "No results". + if (ytSearching) { VideoYoutube.searchChannels(q) - .then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; paint(seq); }) - .catch(function () { if (seq !== reqSeq) return; curYt = []; paint(seq); }); + .then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; ytSearching = false; paint(seq); }) + .catch(function () { if (seq !== reqSeq) return; curYt = []; ytSearching = false; paint(seq); }); } } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 4456909c..ddde3055 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2960,3 +2960,18 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vd-yt-plvid-title { font-size: 12px; font-weight: 700; color: #fff; line-height: 1.3; margin-top: 7px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .vd-yt-plvid .vd-yt-wish { align-self: flex-start; margin-top: 7px; } + +/* YouTube search-results loading state (skeleton cards + "searching…" label) */ +.vsr-yt-loading { margin-left: 10px; font-size: 12px; font-weight: 600; color: #ff8a8a; opacity: 0.85; } +.vsr-yt-loading::after { content: ''; display: inline-block; width: 10px; height: 10px; margin-left: 7px; + vertical-align: -1px; border: 2px solid rgba(255,107,107,0.35); border-top-color: #ff6b6b; border-radius: 50%; + animation: vytSpin 0.7s linear infinite; } +@keyframes vytSpin { to { transform: rotate(360deg); } } +.vyt-skel { background: linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.05) 70%); + background-size: 200% 100%; animation: vytShimmer 1.3s ease-in-out infinite; } +@keyframes vytShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } +.vyt-result--skel { pointer-events: none; } +.vyt-result--skel .vyt-result-art { border-color: rgba(255,255,255,0.08); } +.vyt-skel-line { height: 11px; border-radius: 5px; width: 78px; } +.vyt-skel-line--sm { height: 9px; width: 52px; } +@media (prefers-reduced-motion: reduce) { .vyt-skel, .vsr-yt-loading::after { animation: none; } }