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