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:
parent
284acc8591
commit
0ff0d07c22
3 changed files with 43 additions and 9 deletions
|
|
@ -280,7 +280,7 @@ def search_channels(query, limit=6, ydl_factory=None):
|
||||||
"youtube_id": cid,
|
"youtube_id": cid,
|
||||||
"title": title,
|
"title": title,
|
||||||
"handle": uid if str(uid or "").startswith("@") else None,
|
"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"),
|
"subscriber_count": e.get("channel_follower_count"),
|
||||||
"video_count": e.get("playlist_count"),
|
"video_count": e.get("playlist_count"),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB groups (movies/shows/people) + a YouTube channels group, each painted
|
// TMDB groups (movies/shows/people) + a YouTube channels group, each painted
|
||||||
// as soon as its source resolves (they're fetched in parallel).
|
// as soon as its source resolves (they're fetched in parallel). While YouTube
|
||||||
function render(results, ytChannels) {
|
// 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]');
|
var host = $('[data-video-search-results]');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
@ -101,6 +116,8 @@
|
||||||
'<div class="vsr-grid vyt-result-grid">' +
|
'<div class="vsr-grid vyt-result-grid">' +
|
||||||
ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') +
|
ytChannels.map(function (c) { return VideoYoutube.channelResultCard(c); }).join('') +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
} else if (ytSearching) {
|
||||||
|
html += ytSkeletonGroup();
|
||||||
}
|
}
|
||||||
var any = html.length > 0;
|
var any = html.length > 0;
|
||||||
show('[data-video-search-hint]', false);
|
show('[data-video-search-hint]', false);
|
||||||
|
|
@ -138,14 +155,15 @@
|
||||||
loadTrending();
|
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) {
|
function paint(seq) {
|
||||||
if (seq !== reqSeq) return;
|
if (seq !== reqSeq) return;
|
||||||
render(curResults || [], curYt || []);
|
render(curResults || [], curYt, ytSearching);
|
||||||
}
|
}
|
||||||
function runSearch(q) {
|
function runSearch(q) {
|
||||||
var seq = ++reqSeq;
|
var seq = ++reqSeq;
|
||||||
curResults = null; curYt = null;
|
curResults = null; curYt = null;
|
||||||
|
ytSearching = !!(window.VideoYoutube && q.length >= 2);
|
||||||
show('[data-video-search-loading]', true);
|
show('[data-video-search-loading]', true);
|
||||||
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } })
|
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } })
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
|
@ -161,11 +179,12 @@
|
||||||
curResults = [];
|
curResults = [];
|
||||||
paint(seq);
|
paint(seq);
|
||||||
});
|
});
|
||||||
// YouTube channels in parallel (best-effort) — appended as their own group.
|
// YouTube channels in parallel (best-effort) — its own group, shown as a
|
||||||
if (window.VideoYoutube && q.length >= 2) {
|
// "searching…" skeleton until it resolves so nothing flashes "No results".
|
||||||
|
if (ytSearching) {
|
||||||
VideoYoutube.searchChannels(q)
|
VideoYoutube.searchChannels(q)
|
||||||
.then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; paint(seq); })
|
.then(function (d) { if (seq !== reqSeq) return; curYt = (d && d.channels) || []; ytSearching = false; paint(seq); })
|
||||||
.catch(function () { if (seq !== reqSeq) return; curYt = []; paint(seq); });
|
.catch(function () { if (seq !== reqSeq) return; curYt = []; ytSearching = false; paint(seq); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
.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; }
|
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; }
|
.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; } }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue