Add music video download with progress and metadata matching
Click any video card in Music Videos tab to download. Flow: 1. Search primary metadata source for clean artist/title 2. Fall back to YouTube title parsing if no match 3. Download video via yt-dlp (best quality MP4) 4. Save to configured Music Videos folder as Artist/Title-video.mp4 UI shows circular progress ring on the thumbnail during download, green checkmark on completion, red X on error (clickable to retry). Cards are non-interactive while downloading. Backend: /api/music-video/download and /api/music-video/status endpoints YouTube client: download_music_video() method keeps video format
This commit is contained in:
parent
b44bb34b44
commit
54b7a0f0e8
4 changed files with 339 additions and 3 deletions
|
|
@ -1070,6 +1070,65 @@ class YouTubeClient:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def download_music_video(self, video_url: str, output_path: str,
|
||||||
|
progress_callback=None) -> Optional[str]:
|
||||||
|
"""Download a YouTube video as a music video file (keeps video, not audio-only).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_url: YouTube video URL
|
||||||
|
output_path: Full path for the output file (without extension — yt-dlp adds it)
|
||||||
|
progress_callback: Optional callback(percent: float) for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final file path if successful, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
def _progress_hook(d):
|
||||||
|
if progress_callback and d.get('status') == 'downloading':
|
||||||
|
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
|
||||||
|
downloaded = d.get('downloaded_bytes', 0)
|
||||||
|
if total > 0:
|
||||||
|
progress_callback(downloaded / total * 100)
|
||||||
|
|
||||||
|
download_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
||||||
|
'merge_output_format': 'mp4',
|
||||||
|
'outtmpl': output_path + '.%(ext)s',
|
||||||
|
'noplaylist': True,
|
||||||
|
'progress_hooks': [_progress_hook],
|
||||||
|
'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:
|
||||||
|
download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||||||
|
info = ydl.extract_info(video_url, download=True)
|
||||||
|
final_path = Path(ydl.prepare_filename(info))
|
||||||
|
# yt-dlp may have merged to mp4
|
||||||
|
mp4_path = final_path.with_suffix('.mp4')
|
||||||
|
if mp4_path.exists():
|
||||||
|
return str(mp4_path)
|
||||||
|
if final_path.exists():
|
||||||
|
return str(final_path)
|
||||||
|
# Check for any file matching the stem
|
||||||
|
for f in final_path.parent.glob(f"{final_path.stem}.*"):
|
||||||
|
if f.suffix in ('.mp4', '.mkv', '.webm'):
|
||||||
|
return str(f)
|
||||||
|
logger.error(f"Music video download completed but file not found: {final_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Music video download failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
"""
|
"""
|
||||||
Get all active downloads (matches Soulseek interface).
|
Get all active downloads (matches Soulseek interface).
|
||||||
|
|
|
||||||
132
web_server.py
132
web_server.py
|
|
@ -8346,6 +8346,138 @@ def stream_enhanced_search_track():
|
||||||
logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True)
|
logger.error(f"❌ Error streaming enhanced search track: {e}", exc_info=True)
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MUSIC VIDEO DOWNLOADS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_music_video_downloads = {} # {video_id: {status, progress, path, error}}
|
||||||
|
|
||||||
|
@app.route('/api/music-video/download', methods=['POST'])
|
||||||
|
def download_music_video():
|
||||||
|
"""Download a YouTube video as a music video file to the configured music videos folder."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data"}), 400
|
||||||
|
|
||||||
|
video_id = data.get('video_id', '')
|
||||||
|
video_url = data.get('url', '')
|
||||||
|
raw_title = data.get('title', '')
|
||||||
|
raw_channel = data.get('channel', '')
|
||||||
|
|
||||||
|
if not video_id or not video_url:
|
||||||
|
return jsonify({"error": "Missing video_id or url"}), 400
|
||||||
|
|
||||||
|
# Check if already downloading
|
||||||
|
if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading':
|
||||||
|
return jsonify({"error": "Already downloading"}), 409
|
||||||
|
|
||||||
|
# Get music videos path
|
||||||
|
music_videos_path = config_manager.get('library.music_videos_path', './MusicVideos')
|
||||||
|
music_videos_path = docker_resolve_path(music_videos_path)
|
||||||
|
os.makedirs(music_videos_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize download state
|
||||||
|
_music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None}
|
||||||
|
|
||||||
|
def _do_download():
|
||||||
|
try:
|
||||||
|
# Step 1: Try to match against primary metadata source for clean artist/title
|
||||||
|
_music_video_downloads[video_id]['status'] = 'matching'
|
||||||
|
artist_name = raw_channel
|
||||||
|
track_title = raw_title
|
||||||
|
|
||||||
|
# Strip common YouTube suffixes for cleaner search
|
||||||
|
import re as _re
|
||||||
|
clean_search = _re.sub(r'\s*[\(\[](official\s*(music\s*)?video|official\s*lyric\s*video|official\s*audio|official\s*hd|hd|4k|remastered|lyric\s*video|visualizer|audio)[\)\]]', '', raw_title, flags=_re.IGNORECASE).strip()
|
||||||
|
clean_search = _re.sub(r'\s*-\s*$', '', clean_search).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
fallback_client = _get_metadata_fallback_client()
|
||||||
|
results = fallback_client.search_tracks(clean_search, limit=5)
|
||||||
|
if results:
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
best = None
|
||||||
|
best_score = 0
|
||||||
|
for r in results:
|
||||||
|
name_sim = SequenceMatcher(None, clean_search.lower(), r.name.lower()).ratio()
|
||||||
|
if r.artists:
|
||||||
|
artist_sim = SequenceMatcher(None, raw_channel.lower(), r.artists[0].lower()).ratio()
|
||||||
|
name_sim = (name_sim * 0.6) + (artist_sim * 0.4)
|
||||||
|
if name_sim > best_score:
|
||||||
|
best_score = name_sim
|
||||||
|
best = r
|
||||||
|
if best and best_score >= 0.5:
|
||||||
|
artist_name = best.artists[0] if best.artists else raw_channel
|
||||||
|
track_title = best.name
|
||||||
|
print(f"🎬 [Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})")
|
||||||
|
else:
|
||||||
|
# Parse artist from video title: "Artist - Title" pattern
|
||||||
|
if ' - ' in raw_title:
|
||||||
|
parts = raw_title.split(' - ', 1)
|
||||||
|
artist_name = parts[0].strip()
|
||||||
|
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
|
||||||
|
print(f"🎬 [Music Video] No metadata match, using parsed: {artist_name} - {track_title}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [Music Video] Metadata lookup failed: {e}")
|
||||||
|
if ' - ' in raw_title:
|
||||||
|
parts = raw_title.split(' - ', 1)
|
||||||
|
artist_name = parts[0].strip()
|
||||||
|
track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip()
|
||||||
|
|
||||||
|
# Sanitize for filesystem
|
||||||
|
def _sanitize(s):
|
||||||
|
return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.')
|
||||||
|
|
||||||
|
artist_folder = _sanitize(artist_name)
|
||||||
|
video_filename = f"{_sanitize(track_title)}-video"
|
||||||
|
|
||||||
|
# Build output path: MusicVideos/Artist/Title-video
|
||||||
|
artist_dir = os.path.join(music_videos_path, artist_folder)
|
||||||
|
os.makedirs(artist_dir, exist_ok=True)
|
||||||
|
output_path = os.path.join(artist_dir, video_filename)
|
||||||
|
|
||||||
|
# Step 2: Download
|
||||||
|
_music_video_downloads[video_id]['status'] = 'downloading'
|
||||||
|
_music_video_downloads[video_id]['artist'] = artist_name
|
||||||
|
_music_video_downloads[video_id]['title'] = track_title
|
||||||
|
|
||||||
|
def _progress(pct):
|
||||||
|
_music_video_downloads[video_id]['progress'] = round(pct, 1)
|
||||||
|
|
||||||
|
final_path = soulseek_client.youtube.download_music_video(video_url, output_path, progress_callback=_progress)
|
||||||
|
|
||||||
|
if final_path and os.path.exists(final_path):
|
||||||
|
_music_video_downloads[video_id]['status'] = 'completed'
|
||||||
|
_music_video_downloads[video_id]['progress'] = 100
|
||||||
|
_music_video_downloads[video_id]['path'] = final_path
|
||||||
|
print(f"✅ [Music Video] Downloaded: {artist_name} - {track_title} → {final_path}")
|
||||||
|
add_activity_item("🎬", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now")
|
||||||
|
else:
|
||||||
|
_music_video_downloads[video_id]['status'] = 'error'
|
||||||
|
_music_video_downloads[video_id]['error'] = 'Download failed — file not found'
|
||||||
|
print(f"❌ [Music Video] Download failed for: {artist_name} - {track_title}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_music_video_downloads[video_id]['status'] = 'error'
|
||||||
|
_music_video_downloads[video_id]['error'] = str(e)
|
||||||
|
print(f"❌ [Music Video] Error: {e}")
|
||||||
|
|
||||||
|
# Run in background thread
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=_do_download, daemon=True, name=f'music-video-{video_id}').start()
|
||||||
|
|
||||||
|
return jsonify({"success": True, "video_id": video_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/music-video/status/<video_id>', methods=['GET'])
|
||||||
|
def get_music_video_status(video_id):
|
||||||
|
"""Get download status for a music video."""
|
||||||
|
status = _music_video_downloads.get(video_id)
|
||||||
|
if not status:
|
||||||
|
return jsonify({"status": "unknown"})
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/download', methods=['POST'])
|
@app.route('/api/download', methods=['POST'])
|
||||||
def start_download():
|
def start_download():
|
||||||
"""Simple download route"""
|
"""Simple download route"""
|
||||||
|
|
|
||||||
|
|
@ -8917,10 +8917,18 @@ function initializeSearchModeToggle() {
|
||||||
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
|
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) : '';
|
const views = v.view_count ? _formatViewCount(v.view_count) : '';
|
||||||
return `
|
return `
|
||||||
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="window.open('${v.url}', '_blank')">
|
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${JSON.stringify(v).replace(/"/g, '"')})">
|
||||||
<div class="enh-video-thumb">
|
<div class="enh-video-thumb">
|
||||||
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
|
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
|
||||||
<div class="enh-video-play">▶</div>
|
<div class="enh-video-play">▶</div>
|
||||||
|
<div class="enh-video-progress-ring hidden">
|
||||||
|
<svg viewBox="0 0 36 36">
|
||||||
|
<circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/>
|
||||||
|
<circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="enh-video-done hidden">✓</div>
|
||||||
|
<div class="enh-video-error hidden">✗</div>
|
||||||
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
|
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="enh-video-info">
|
<div class="enh-video-info">
|
||||||
|
|
@ -8932,6 +8940,74 @@ function initializeSearchModeToggle() {
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window._downloadMusicVideo = async function(cardEl, video) {
|
||||||
|
if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return;
|
||||||
|
cardEl.classList.add('downloading');
|
||||||
|
cardEl.onclick = null; // Disable click
|
||||||
|
|
||||||
|
const playBtn = cardEl.querySelector('.enh-video-play');
|
||||||
|
const progressRing = cardEl.querySelector('.enh-video-progress-ring');
|
||||||
|
const progressBar = cardEl.querySelector('.enh-video-progress-bar');
|
||||||
|
const doneIcon = cardEl.querySelector('.enh-video-done');
|
||||||
|
const errorIcon = cardEl.querySelector('.enh-video-error');
|
||||||
|
|
||||||
|
if (playBtn) playBtn.classList.add('hidden');
|
||||||
|
if (progressRing) progressRing.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/music-video/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
video_id: video.video_id,
|
||||||
|
url: video.url,
|
||||||
|
title: video.title,
|
||||||
|
channel: video.channel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Download request failed');
|
||||||
|
|
||||||
|
// Poll for progress
|
||||||
|
const circumference = 97.4; // 2 * PI * 15.5
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch(`/api/music-video/status/${video.video_id}`);
|
||||||
|
const status = await statusRes.json();
|
||||||
|
|
||||||
|
if (progressBar && status.progress > 0) {
|
||||||
|
const offset = circumference - (status.progress / 100) * circumference;
|
||||||
|
progressBar.style.strokeDashoffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
cardEl.classList.remove('downloading');
|
||||||
|
cardEl.classList.add('completed');
|
||||||
|
if (progressRing) progressRing.classList.add('hidden');
|
||||||
|
if (doneIcon) doneIcon.classList.remove('hidden');
|
||||||
|
} else if (status.status === 'error') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
cardEl.classList.remove('downloading');
|
||||||
|
cardEl.classList.add('errored');
|
||||||
|
if (progressRing) progressRing.classList.add('hidden');
|
||||||
|
if (errorIcon) errorIcon.classList.remove('hidden');
|
||||||
|
// Re-enable click for retry
|
||||||
|
cardEl.onclick = () => window._downloadMusicVideo(cardEl, video);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Polling error — keep trying
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
cardEl.classList.remove('downloading');
|
||||||
|
if (progressRing) progressRing.classList.add('hidden');
|
||||||
|
if (playBtn) playBtn.classList.remove('hidden');
|
||||||
|
if (errorIcon) errorIcon.classList.remove('hidden');
|
||||||
|
cardEl.onclick = () => window._downloadMusicVideo(cardEl, video);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function _formatViewCount(count) {
|
function _formatViewCount(count) {
|
||||||
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
|
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
|
||||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
|
@ -17691,8 +17767,11 @@ function _gsRender(data) {
|
||||||
h += videos.map(v => {
|
h += videos.map(v => {
|
||||||
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
|
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 || '');
|
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')">
|
return `<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${_escToast(JSON.stringify(v)).replace(/"/g, '"')})">
|
||||||
<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-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play">▶</div>
|
||||||
|
<div class="enh-video-progress-ring hidden"><svg viewBox="0 0 36 36"><circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/></svg></div>
|
||||||
|
<div class="enh-video-done hidden">✓</div><div class="enh-video-error hidden">✗</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 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>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
|
||||||
|
|
@ -32982,6 +32982,72 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video download states */
|
||||||
|
.enh-video-card.downloading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-card.completed .enh-video-thumb::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-card.errored .enh-video-thumb::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(180, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-progress-ring {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-progress-ring svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-progress-bar {
|
||||||
|
transition: stroke-dashoffset 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-done, .enh-video-error {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-done {
|
||||||
|
background: rgba(29, 185, 84, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enh-video-error {
|
||||||
|
background: rgba(220, 50, 50, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.enh-empty-state {
|
.enh-empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue