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:
parent
1f0ef08b48
commit
b44bb34b44
4 changed files with 342 additions and 9 deletions
|
|
@ -558,9 +558,79 @@ class YouTubeClient:
|
|||
thumbnail = thumbs[-1].get('url')
|
||||
|
||||
track_result.thumbnail = thumbnail
|
||||
|
||||
|
||||
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]]:
|
||||
"""
|
||||
Search YouTube for tracks matching the query (async, Soulseek-compatible interface).
|
||||
|
|
|
|||
|
|
@ -7873,6 +7873,8 @@ def enhanced_search():
|
|||
alternate_sources.append('discogs')
|
||||
if primary_source != 'hydrabase' and hydrabase_available:
|
||||
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, "
|
||||
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.
|
||||
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
|
||||
|
||||
data = request.get_json()
|
||||
|
|
@ -7967,6 +7969,37 @@ def enhanced_search_source(source_name):
|
|||
if not query:
|
||||
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:
|
||||
client = None
|
||||
if source_name == 'spotify':
|
||||
|
|
|
|||
|
|
@ -8330,6 +8330,7 @@ function initializeSearchModeToggle() {
|
|||
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
|
||||
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
|
||||
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
|
||||
|
|
@ -8454,7 +8455,7 @@ function initializeSearchModeToggle() {
|
|||
// Fire ALL source fetches immediately in parallel with the primary endpoint.
|
||||
// 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.
|
||||
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) {
|
||||
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']) {
|
||||
_fetchAlternateSource(srcName, query);
|
||||
}
|
||||
|
||||
|
|
@ -8512,6 +8513,9 @@ function initializeSearchModeToggle() {
|
|||
}
|
||||
|
||||
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)
|
||||
const displaySource = _activeSearchSource || data.metadata_source || '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
|
||||
if (!_enhancedSearchData) return;
|
||||
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];
|
||||
|
||||
|
|
@ -8750,6 +8755,7 @@ function initializeSearchModeToggle() {
|
|||
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 === '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; }
|
||||
|
||||
// Re-render tabs + content if this is the active source
|
||||
|
|
@ -8797,7 +8803,9 @@ function initializeSearchModeToggle() {
|
|||
tabBar.innerHTML = ordered.map(name => {
|
||||
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
||||
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;
|
||||
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
|
||||
onclick="window._switchEnhSourceTab('${name}')"
|
||||
|
|
@ -8822,6 +8830,27 @@ function initializeSearchModeToggle() {
|
|||
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
|
||||
const viewData = {
|
||||
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, '"')}">${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
|
||||
async function lazyLoadEnhancedSearchArtistImages() {
|
||||
const artistLists = [
|
||||
|
|
@ -17543,7 +17628,8 @@ async function _gsFetchSourceStream(src, query) {
|
|||
if (!res.ok) return;
|
||||
|
||||
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];
|
||||
|
||||
|
|
@ -17566,6 +17652,7 @@ async function _gsFetchSourceStream(src, query) {
|
|||
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 === '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;
|
||||
_gsRenderTabs();
|
||||
// Re-render content if this is the active source tab
|
||||
|
|
@ -17585,6 +17672,39 @@ function _gsRender(data) {
|
|||
const results = document.getElementById('gsearch-results');
|
||||
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 loading = src._loading || new Set();
|
||||
const dbArtists = data?.db_artists || [];
|
||||
|
|
@ -17602,7 +17722,7 @@ function _gsRender(data) {
|
|||
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 || '';
|
||||
|
||||
let h = '';
|
||||
|
|
@ -17699,11 +17819,13 @@ function _gsRenderTabs() {
|
|||
if (!el) return;
|
||||
const sources = Object.keys(_gsState.sources);
|
||||
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.innerHTML = sources.map(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>`;
|
||||
}).join('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-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-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 {
|
||||
margin-bottom: 24px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue