Add Music Videos search tab to enhanced and global search

New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs
in both enhanced search and global search. Searches YouTube via yt-dlp
and displays results in a video card grid with 16:9 thumbnails, play
overlay, duration badge, channel name, and view count.

- Backend: /api/enhanced-search/source/youtube_videos endpoint with
  search_videos() method on YouTubeClient returning YouTubeSearchResult
- Frontend: Video grid layout with responsive cards, YouTube red tab
  color, proper section hiding when switching between metadata and
  video tabs
- Global search: Full parity with enhanced search video rendering
- No download functionality yet — display only
This commit is contained in:
Broque Thomas 2026-04-10 23:07:14 -07:00
parent 1f0ef08b48
commit b44bb34b44
4 changed files with 342 additions and 9 deletions

View file

@ -558,9 +558,79 @@ class YouTubeClient:
thumbnail = thumbs[-1].get('url') thumbnail = thumbs[-1].get('url')
track_result.thumbnail = thumbnail track_result.thumbnail = thumbnail
return track_result return track_result
async def search_videos(self, query: str, max_results: int = 20) -> List[YouTubeSearchResult]:
"""Search YouTube and return video metadata for music video display.
Unlike search() which returns TrackResult objects for download matching,
this returns YouTubeSearchResult objects with video-specific metadata
(thumbnails, view counts, channel names) for UI display.
"""
logger.info(f"🎬 Searching YouTube videos for: {query}")
try:
loop = asyncio.get_event_loop()
def _search():
from config.settings import config_manager
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'default_search': 'ytsearch',
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
data = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
if not data or 'entries' not in data:
return []
results = []
for entry in data['entries']:
if not entry:
continue
video_id = entry.get('id', '')
title = entry.get('title', '')
if not video_id or not title:
continue
# Skip very short clips (< 30s) and very long content (> 15min)
duration = entry.get('duration') or 0
if duration < 30 or duration > 900:
continue
channel = entry.get('uploader', entry.get('channel', ''))
if channel and re.search(r'\s*-\s*Topic\s*$', channel, re.IGNORECASE):
channel = re.sub(r'\s*-\s*Topic\s*$', '', channel, flags=re.IGNORECASE).strip()
thumbnail = entry.get('thumbnail')
if not thumbnail and entry.get('thumbnails'):
thumbs = entry['thumbnails']
if isinstance(thumbs, list) and thumbs:
thumbnail = thumbs[-1].get('url')
results.append(YouTubeSearchResult(
video_id=video_id,
title=title,
channel=channel,
duration=duration,
url=f"https://www.youtube.com/watch?v={video_id}",
thumbnail=thumbnail or '',
view_count=entry.get('view_count', 0) or 0,
upload_date=entry.get('upload_date', ''),
))
return results
return await loop.run_in_executor(None, _search)
except Exception as e:
logger.error(f"YouTube video search failed: {e}")
return []
async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]: async def search(self, query: str, timeout: int = None, progress_callback=None) -> tuple[List[TrackResult], List[AlbumResult]]:
""" """
Search YouTube for tracks matching the query (async, Soulseek-compatible interface). Search YouTube for tracks matching the query (async, Soulseek-compatible interface).

View file

@ -7873,6 +7873,8 @@ def enhanced_search():
alternate_sources.append('discogs') alternate_sources.append('discogs')
if primary_source != 'hydrabase' and hydrabase_available: if primary_source != 'hydrabase' and hydrabase_available:
alternate_sources.append('hydrabase') alternate_sources.append('hydrabase')
# YouTube music videos always available (uses yt-dlp, no auth needed)
alternate_sources.append('youtube_videos')
logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, " logger.info(f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, " f"{len(primary_results['artists'])} artists, {len(primary_results['albums'])} albums, "
@ -7959,7 +7961,7 @@ def enhanced_search_source(source_name):
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI. This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
Falls back to single JSON response if streaming not supported. Falls back to single JSON response if streaming not supported.
""" """
if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'): if source_name not in ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos'):
return jsonify({"error": f"Unknown source: {source_name}"}), 400 return jsonify({"error": f"Unknown source: {source_name}"}), 400
data = request.get_json() data = request.get_json()
@ -7967,6 +7969,37 @@ def enhanced_search_source(source_name):
if not query: if not query:
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
# YouTube music videos — separate flow from metadata sources
if source_name == 'youtube_videos':
if not soulseek_client or not hasattr(soulseek_client, 'youtube') or not soulseek_client.youtube:
return jsonify({"videos": [], "available": False})
try:
def generate_videos():
try:
# Search YouTube via yt-dlp
video_query = f"{query} official music video"
results = run_async(soulseek_client.youtube.search_videos(video_query, max_results=20))
videos = []
for v in (results or []):
videos.append({
'video_id': v.video_id,
'title': v.title,
'channel': v.channel,
'duration': v.duration,
'thumbnail': v.thumbnail,
'url': v.url,
'view_count': v.view_count,
'upload_date': v.upload_date,
})
yield json.dumps({"type": "videos", "data": videos}) + "\n"
except Exception as e:
logger.error(f"YouTube music video search failed: {e}")
yield json.dumps({"type": "videos", "data": []}) + "\n"
yield json.dumps({"type": "done"}) + "\n"
return app.response_class(generate_videos(), mimetype='application/x-ndjson')
except Exception as e:
return jsonify({"error": str(e)}), 500
try: try:
client = None client = None
if source_name == 'spotify': if source_name == 'spotify':

View file

@ -8330,6 +8330,7 @@ function initializeSearchModeToggle() {
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
}; };
// Live search with debouncing // Live search with debouncing
@ -8454,7 +8455,7 @@ function initializeSearchModeToggle() {
// Fire ALL source fetches immediately in parallel with the primary endpoint. // Fire ALL source fetches immediately in parallel with the primary endpoint.
// Don't guess which is primary — the main endpoint response will tell us. // Don't guess which is primary — the main endpoint response will tell us.
// If an alternate duplicates the primary, it just overwrites with same data. // If an alternate duplicates the primary, it just overwrites with same data.
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) { for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) {
_fetchAlternateSource(srcName, query); _fetchAlternateSource(srcName, query);
} }
@ -8512,6 +8513,9 @@ function initializeSearchModeToggle() {
} }
function renderDropdownResults(data) { function renderDropdownResults(data) {
// Music Videos tab — don't render regular sections
if (_activeSearchSource === 'youtube_videos') return;
// Determine source badge from active tab (not just primary) // Determine source badge from active tab (not just primary)
const displaySource = _activeSearchSource || data.metadata_source || 'spotify'; const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify; const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
@ -8726,7 +8730,8 @@ function initializeSearchModeToggle() {
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
if (!_enhancedSearchData) return; if (!_enhancedSearchData) return;
if (!_enhancedSearchData.sources[sourceName]) { if (!_enhancedSearchData.sources[sourceName]) {
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) }; const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
} }
const sourceData = _enhancedSearchData.sources[sourceName]; const sourceData = _enhancedSearchData.sources[sourceName];
@ -8750,6 +8755,7 @@ function initializeSearchModeToggle() {
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
else if (chunk.type === 'done') { delete sourceData._loading; break; } else if (chunk.type === 'done') { delete sourceData._loading; break; }
// Re-render tabs + content if this is the active source // Re-render tabs + content if this is the active source
@ -8797,7 +8803,9 @@ function initializeSearchModeToggle() {
tabBar.innerHTML = ordered.map(name => { tabBar.innerHTML = ordered.map(name => {
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
const src = sources[name] || {}; const src = sources[name] || {};
const count = (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); const count = name === 'youtube_videos'
? (src.videos?.length || 0)
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
const isActive = name === _activeSearchSource; const isActive = name === _activeSearchSource;
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}" return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')" onclick="window._switchEnhSourceTab('${name}')"
@ -8822,6 +8830,27 @@ function initializeSearchModeToggle() {
tab.classList.toggle('active', tab.dataset.source === sourceName); tab.classList.toggle('active', tab.dataset.source === sourceName);
}); });
// Music Videos tab — render video cards instead of regular sections
if (sourceName === 'youtube_videos') {
// Hide ALL regular sections including wrappers
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
});
// Hide the artists wrapper div too
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = 'none';
_renderVideoResults(src.videos || []);
resultsContainer.classList.remove('hidden');
return;
}
// Hide videos section and restore regular layout when switching to a metadata tab
const videosSec = document.getElementById('enh-videos-section');
if (videosSec) videosSec.classList.add('hidden');
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
if (artistsWrapper) artistsWrapper.style.display = '';
// Build data in the shape renderDropdownResults expects // Build data in the shape renderDropdownResults expects
const viewData = { const viewData = {
db_artists: _enhancedSearchData.db_artists, db_artists: _enhancedSearchData.db_artists,
@ -8854,6 +8883,62 @@ function initializeSearchModeToggle() {
} }
}; };
function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section');
if (!section) {
// Create the section dynamically if it doesn't exist
const container = document.getElementById('enhanced-results-container');
if (!container) return;
section = document.createElement('div');
section.id = 'enh-videos-section';
section.className = 'enh-dropdown-section';
section.innerHTML = `
<div class="enh-section-header">
<span class="enh-section-icon">🎬</span>
<h4 class="enh-section-title">Music Videos</h4>
<span class="enh-section-count" id="enh-videos-count">0</span>
</div>
<div class="enh-video-grid" id="enh-videos-list"></div>
`;
container.appendChild(section);
}
section.classList.remove('hidden');
const countEl = document.getElementById('enh-videos-count');
const listEl = document.getElementById('enh-videos-list');
if (countEl) countEl.textContent = videos.length;
if (!videos.length) {
listEl.innerHTML = '<div class="enh-empty-state">No music videos found</div>';
return;
}
listEl.innerHTML = videos.map(v => {
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count ? _formatViewCount(v.view_count) : '';
return `
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="window.open('${v.url}', '_blank')">
<div class="enh-video-thumb">
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="enh-video-play"></div>
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
</div>
<div class="enh-video-info">
<div class="enh-video-title" title="${v.title.replace(/"/g, '&quot;')}">${v.title}</div>
<div class="enh-video-channel">${v.channel}${views ? ` · ${views} views` : ''}</div>
</div>
</div>
`;
}).join('');
}
function _formatViewCount(count) {
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return String(count);
}
// Lazy load artist images for enhanced search results // Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() { async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [ const artistLists = [
@ -17543,7 +17628,8 @@ async function _gsFetchSourceStream(src, query) {
if (!res.ok) return; if (!res.ok) return;
if (!_gsState.sources[src]) { if (!_gsState.sources[src]) {
_gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) }; const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
} }
const sourceData = _gsState.sources[src]; const sourceData = _gsState.sources[src];
@ -17566,6 +17652,7 @@ async function _gsFetchSourceStream(src, query) {
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
if (chunk.type === 'done') delete sourceData._loading; if (chunk.type === 'done') delete sourceData._loading;
_gsRenderTabs(); _gsRenderTabs();
// Re-render content if this is the active source tab // Re-render content if this is the active source tab
@ -17585,6 +17672,39 @@ function _gsRender(data) {
const results = document.getElementById('gsearch-results'); const results = document.getElementById('gsearch-results');
if (!results) return; if (!results) return;
// Music Videos tab — render video grid instead of regular results
if (_gsState.activeSource === 'youtube_videos') {
const src = _gsState.sources['youtube_videos'] || {};
const videos = src.videos || [];
const isLoading = src._loading && src._loading.size > 0;
let h = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
h += '<div class="gsearch-results-body">';
if (isLoading) {
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
} else if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
} else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">';
h += videos.map(v => {
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || '');
return `<div class="enh-video-card" onclick="window.open('${v.url}', '_blank')">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play"></div>${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
</div>`;
}).join('');
h += '</div>';
}
h += '</div>';
results.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
return;
}
const src = _gsState.sources[_gsState.activeSource] || {}; const src = _gsState.sources[_gsState.activeSource] || {};
const loading = src._loading || new Set(); const loading = src._loading || new Set();
const dbArtists = data?.db_artists || []; const dbArtists = data?.db_artists || [];
@ -17602,7 +17722,7 @@ function _gsRender(data) {
return; return;
} }
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
let h = ''; let h = '';
@ -17699,11 +17819,13 @@ function _gsRenderTabs() {
if (!el) return; if (!el) return;
const sources = Object.keys(_gsState.sources); const sources = Object.keys(_gsState.sources);
if (sources.length < 2) { el.style.display = 'none'; return; } if (sources.length < 2) { el.style.display = 'none'; return; }
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
el.style.display = 'flex'; el.style.display = 'flex';
el.innerHTML = sources.map(s => { el.innerHTML = sources.map(s => {
const d = _gsState.sources[s]; const d = _gsState.sources[s];
const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); const c = s === 'youtube_videos'
? (d.videos?.length || 0)
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`; return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
}).join(''); }).join('');
} }

View file

@ -32887,6 +32887,114 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; } .enh-source-tab.enh-tab-deezer.active { background: rgba(162, 56, 255, 0.2); color: #a238ff; }
.enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; } .enh-source-tab.enh-tab-discogs.active { background: rgba(212, 165, 116, 0.2); color: #D4A574; }
.enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; } .enh-source-tab.enh-tab-hydrabase.active { background: rgba(0, 180, 216, 0.2); color: #00b4d8; }
.enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; }
/* Music Video Grid */
.enh-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
padding: 4px 0;
}
.enh-video-card {
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.enh-video-card:hover {
transform: translateY(-3px);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
.enh-video-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.enh-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.enh-video-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.enh-video-card:hover .enh-video-play {
opacity: 1;
}
.enh-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.3px;
}
.enh-video-info {
padding: 10px 12px;
}
.enh-video-title {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.enh-video-channel {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.enh-empty-state {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
@media (max-width: 600px) {
.enh-video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
}
.enh-dropdown-section { .enh-dropdown-section {
margin-bottom: 24px; margin-bottom: 24px;