diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js
index 04ed957e..1a1a176a 100644
--- a/webui/static/video/video-detail.js
+++ b/webui/static/video/video-detail.js
@@ -879,6 +879,11 @@
function renderSeasonNav() {
var host = q('[data-vd-season-nav]');
if (!host || !data || !data.seasons.length) { if (host) host.innerHTML = ''; return; }
+ if (data.source === 'youtube') { // channels: search + sort controls, then year pills (hidden in flat mode)
+ host.className = 'vd-season-nav vd-season-nav--yt';
+ host.innerHTML = ytControlsHTML() + (ytFlatMode() ? '' : pillsHTML());
+ return;
+ }
host.className = 'vd-season-nav vd-season-nav--' + seasonView;
if (seasonView === 'rail') host.innerHTML = railHTML();
else if (seasonView === 'timeline') host.innerHTML = timelineHTML();
@@ -1068,9 +1073,10 @@
var season = seasonByNum(selectedSeason);
if (!season) { host.innerHTML = ''; return; }
var eps = missingOnly ? season.episodes.filter(function (e) { return !e.owned; }) : season.episodes;
- host.innerHTML = eps.length
- ? eps.map(episodeRow).join('')
- : '
No ' + (missingOnly ? 'missing ' : '') + 'episodes here. 🎉
';
+ var emptyMsg = (data && data.source === 'youtube')
+ ? (ytFilter.q ? 'No videos match “' + esc(ytFilter.q) + '”.' : 'No videos here.')
+ : 'No ' + (missingOnly ? 'missing ' : '') + 'episodes here. 🎉';
+ host.innerHTML = eps.length ? eps.map(episodeRow).join('') : '' + emptyMsg + '
';
host.classList.remove('vd-ep-anim'); void host.offsetWidth; host.classList.add('vd-ep-anim');
}
@@ -1314,35 +1320,109 @@
// d.source='youtube' driving every branch above. All TMDB-only sections
// auto-hide on empty channel data.
var ytVideoMap = {}; // youtube_id -> raw video (for wish add, main grid + playlists)
+ var ytFilter = { q: '', sort: 'newest' }; // channel search + sort
+ var ytSearchTimer = null;
function ytProx(u) { return (window.VideoYoutube && u) ? VideoYoutube.img(u) : (u || ''); }
- function ytToShow(resp) {
- var ch = resp.channel || {};
+ function ytEpisodeOf(v, i) {
+ return { episode_number: i + 1, title: v.title, overview: v.description || '',
+ air_date: v.published_at, owned: !!v.wished, has_still: false,
+ still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id,
+ yt_duration: v.duration || '', view_count: v.view_count || 0 };
+ }
+ function ytDurSecs(d) {
+ if (!d) return 0;
+ return String(d).split(':').reduce(function (acc, n) { return acc * 60 + (parseInt(n, 10) || 0); }, 0);
+ }
+ // Year-grouped seasons (the default "by year" view). asc → oldest first.
+ function ytGroupByYear(videos, ch, asc) {
var byYear = {};
- ytVideoMap = {};
- (ch.videos || []).forEach(function (v) {
- ytVideoMap[v.youtube_id] = v;
+ videos.forEach(function (v) {
var yr = (v.published_at && /^\d{4}/.test(v.published_at)) ? parseInt(v.published_at.slice(0, 4), 10) : 0;
(byYear[yr] = byYear[yr] || []).push(v);
});
- var years = Object.keys(byYear).map(Number).sort(function (a, b) { return b - a; });
- var seasons = years.map(function (yr) {
- var vids = byYear[yr], poster = '';
- for (var k = 0; k < vids.length; k++) { if (vids[k].thumbnail_url) { poster = ytProx(vids[k].thumbnail_url); break; } }
- var eps = vids.map(function (v, i) {
- return { episode_number: i + 1, title: v.title, overview: v.description || '',
- air_date: v.published_at, owned: !!v.wished, has_still: false,
- still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id,
- yt_duration: v.duration || '', view_count: v.view_count || 0 };
+ var years = Object.keys(byYear).map(Number).sort(function (a, b) { return asc ? a - b : b - a; });
+ return years.map(function (yr) {
+ var vids = byYear[yr].slice().sort(function (a, b) {
+ var x = a.published_at || '', y = b.published_at || '';
+ return asc ? (x < y ? -1 : x > y ? 1 : 0) : (x > y ? -1 : x < y ? 1 : 0);
});
+ var poster = '';
+ for (var k = 0; k < vids.length; k++) { if (vids[k].thumbnail_url) { poster = ytProx(vids[k].thumbnail_url); break; } }
+ var eps = vids.map(ytEpisodeOf);
var wishedN = eps.filter(function (e) { return e.owned; }).length;
- // No upload dates (flat listing omits them) → a single "All Videos"
- // season instead of a lone "Undated"; mixed → label the dateless bucket.
var label = yr ? String(yr) : (years.length === 1 ? 'All Videos' : 'Earlier videos');
return { season_number: yr, title: label, poster_url: poster || ytProx(ch.avatar_url),
episode_owned: wishedN, episode_total: eps.length, episodes: eps };
});
+ }
+ // A search OR a popularity/length sort collapses the year view into one flat,
+ // sorted "results" list instead of per-year seasons.
+ function ytFlatMode() { return !!ytFilter.q || ytFilter.sort === 'views' || ytFilter.sort === 'longest'; }
+ function ytVisibleVideos() {
+ var all = (data && data._channel && data._channel.videos) || [];
+ var q = (ytFilter.q || '').toLowerCase().trim();
+ var vids = q ? all.filter(function (v) { return (v.title || '').toLowerCase().indexOf(q) >= 0; }) : all.slice();
+ if (ytFilter.sort === 'views') vids.sort(function (a, b) { return (b.view_count || 0) - (a.view_count || 0); });
+ else if (ytFilter.sort === 'longest') vids.sort(function (a, b) { return ytDurSecs(b.duration) - ytDurSecs(a.duration); });
+ else if (ytFilter.sort === 'oldest') vids.sort(function (a, b) { var x = a.published_at || '', y = b.published_at || ''; return x < y ? -1 : x > y ? 1 : 0; });
+ else vids.sort(function (a, b) { var x = a.published_at || '', y = b.published_at || ''; return x > y ? -1 : x < y ? 1 : 0; });
+ return vids;
+ }
+ function ytFlatSeason(vids) {
+ var ch = (data && data._channel) || {};
+ var title = ytFilter.q ? (vids.length + ' result' + (vids.length === 1 ? '' : 's'))
+ : (ytFilter.sort === 'views' ? 'Most viewed' : 'Longest');
+ return { season_number: -1, title: title, poster_url: ytProx(ch.avatar_url),
+ episode_owned: 0, episode_total: vids.length, episodes: vids.map(ytEpisodeOf) };
+ }
+ function ytRebuildMap() {
+ ytVideoMap = {};
+ ((data && data._channel && data._channel.videos) || []).forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
+ }
+ // Re-derive data.seasons from the master list honouring the active filter/sort.
+ // force=true always re-renders the grid (a filter change); else only when the
+ // viewed season actually changed (so a streaming batch doesn't flicker).
+ function ytRegroup(force) {
+ if (!data || !data._channel) return;
+ var prevSel = selectedSeason, prevObj = seasonByNum(prevSel);
+ var prevEp = prevObj ? prevObj.episodes.length : -1;
+ ytRebuildMap();
+ if (ytFlatMode()) {
+ data.seasons = [ytFlatSeason(ytVisibleVideos())];
+ selectedSeason = -1;
+ } else {
+ data.seasons = ytGroupByYear(data._channel.videos.slice(), data._channel, ytFilter.sort === 'oldest');
+ if (!seasonByNum(selectedSeason)) selectedSeason = data.seasons.length ? data.seasons[0].season_number : null;
+ }
+ data.season_count = data.seasons.length;
+ data.episode_total = (data._channel.videos || []).length;
+ renderSeasonNav();
+ var nowObj = seasonByNum(selectedSeason);
+ if (force || selectedSeason !== prevSel || !nowObj || nowObj.episodes.length !== prevEp) renderEpisodes();
+ if (force) ytRefocusSearch();
+ }
+ function ytRefocusSearch() {
+ var inp = q('[data-vd-yt-search]');
+ if (inp && document.activeElement !== inp) { var v = inp.value; inp.focus(); inp.value = ''; inp.value = v; }
+ }
+ function ytControlsHTML() {
+ var sorts = [['newest', 'Newest'], ['oldest', 'Oldest'], ['views', 'Most viewed'], ['longest', 'Longest']];
+ return '' +
+ '
⌕' +
+ '
' +
+ '
';
+ }
+
+ function ytToShow(resp) {
+ var ch = resp.channel || {};
+ ytVideoMap = {};
+ (ch.videos || []).forEach(function (v) { ytVideoMap[v.youtube_id] = v; });
+ var seasons = ytGroupByYear(ch.videos || [], ch, false);
return { kind: 'channel', source: 'youtube', id: ch.youtube_id, title: ch.title || 'Channel',
overview: ch.description || '', backdrop_url: ytProx(ch.banner_url), has_backdrop: !!ch.banner_url,
poster_url: ytProx(ch.avatar_url), has_poster: !!ch.avatar_url, genres: ch.tags || [], handle: ch.handle,
@@ -1374,9 +1454,6 @@
.then(function (resp) {
if (token !== ytLoadAllToken || currentId !== id || currentSource !== 'youtube') return;
if (!resp || !resp.success) { showEpSyncing(false); return; }
- var prevSel = selectedSeason;
- var selObj = seasonByNum(prevSel);
- var prevEp = selObj ? selObj.episodes.length : -1;
var changed = false;
(resp.videos || []).forEach(function (v) {
if (!v.youtube_id) return;
@@ -1389,16 +1466,9 @@
byId[v.youtube_id] = v; data._channel.videos.push(v); changed = true;
}
});
- if (changed) {
- var nd = ytToShow({ channel: data._channel, following: data.following });
- data = nd;
- if (!seasonByNum(prevSel)) selectedSeason = nd.seasons.length ? nd.seasons[0].season_number : null;
- renderBillboard(nd); renderSeasonNav();
- // Only re-render the episode grid if the season you're viewing
- // actually changed (most batches add OLDER years, not yours).
- var nowObj = seasonByNum(selectedSeason);
- if (selectedSeason !== prevSel || !nowObj || nowObj.episodes.length !== prevEp) renderEpisodes();
- }
+ // Re-derive the view from the grown master, honouring the active
+ // filter/sort (only re-renders the grid if the viewed season moved).
+ if (changed) ytRegroup(false);
cont = resp.continuation;
if (cont && data._channel.videos.length < MAX) {
// Quiet when refreshing a remembered channel; only the first
@@ -1420,6 +1490,7 @@
if (currentId !== id) artAttemptedFor = null;
currentId = id;
if (!root()) return;
+ ytFilter = { q: '', sort: 'newest' }; // a fresh channel starts unfiltered
ytCancelLoad();
showLoading(true); resetExtras(); showEpSyncing(false);
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
@@ -1644,6 +1715,19 @@
function init() {
document.addEventListener('soulsync:video-open-detail', onOpen);
document.addEventListener('click', onClick);
+ // Channel search (debounced) + sort — only act on the youtube controls.
+ document.addEventListener('input', function (e) {
+ var inp = e.target && e.target.closest && e.target.closest('[data-vd-yt-search]');
+ if (!inp || !data || data.source !== 'youtube') return;
+ var v = inp.value;
+ if (ytSearchTimer) clearTimeout(ytSearchTimer);
+ ytSearchTimer = setTimeout(function () { ytFilter.q = v; ytRegroup(true); }, 200);
+ });
+ document.addEventListener('change', function (e) {
+ var sel = e.target && e.target.closest && e.target.closest('[data-vd-yt-sort]');
+ if (!sel || !data || data.source !== 'youtube') return;
+ ytFilter.sort = sel.value; ytRegroup(true);
+ });
// Kill the billboard trailer (audio!) when navigating to a non-detail page.
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail !== 'video-movie-detail' && e.detail !== 'video-show-detail') stopBillboardTrailer();
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index d9529368..d1f7831b 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -570,6 +570,25 @@ body[data-side="video"] .dashboard-header-sweep {
padding: 1px 5px; border-radius: 4px; font-size: 11px; font-weight: 700; line-height: 1.4;
color: #fff; background: rgba(0, 0, 0, 0.82); letter-spacing: 0.02em;
}
+
+/* Channel search + sort controls (above the year pills) */
+.vd-season-nav--yt { display: flex; flex-direction: column; gap: 14px; }
+.vd-yt-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.vd-yt-search { position: relative; flex: 1 1 240px; min-width: 200px; }
+.vd-yt-search-ic { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
+ font-size: 15px; color: rgba(255, 255, 255, 0.4); pointer-events: none; }
+.vd-yt-search-in {
+ width: 100%; padding: 9px 12px 9px 34px; border-radius: 10px;
+ background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12);
+ color: #fff; font-size: 13.5px;
+}
+.vd-yt-search-in:focus { outline: none; border-color: rgba(var(--vd-accent-rgb, 255 59 59), 0.6);
+ background: rgba(255, 255, 255, 0.09); }
+.vd-yt-sort {
+ padding: 9px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
+}
+.vd-yt-sort:focus { outline: none; border-color: rgba(var(--vd-accent-rgb, 255 59 59), 0.6); }
.vd-ep-info { min-width: 0; }
.vd-ep-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 5px; }
.vd-ep-title { font-size: 15.5px; font-weight: 700; color: #fff; }