Search UX: no 'No results' flash before YouTube loads + avatar fallback

- 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.
This commit is contained in:
BoulderBadgeDad 2026-06-17 09:42:02 -07:00
parent 284acc8591
commit 0ff0d07c22
3 changed files with 43 additions and 9 deletions

View file

@ -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"),
})

View file

@ -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 += '<div class="vyt-result vyt-result--skel">' +
'<span class="vyt-result-art vyt-skel"></span>' +
'<span class="vyt-result-info"><span class="vyt-skel vyt-skel-line"></span>' +
'<span class="vyt-skel vyt-skel-line vyt-skel-line--sm"></span></span></div>';
}
return '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">▶</span>YouTube channels' +
'<span class="vsr-yt-loading">searching…</span></h2>' +
'<div class="vsr-grid vyt-result-grid">' + cards + '</div></div>';
}
function render(results, ytChannels, ytSearching) {
var host = $('[data-video-search-results]');
if (!host) return;
var html = '';
@ -101,6 +116,8 @@
'<div class="vsr-grid vyt-result-grid">' +
ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') +
'</div></div>';
} 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); });
}
}

View file

@ -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; } }