From 508b93125dccf7903b6c3e62d080a61bf7b5c31b Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Sun, 18 Jan 2026 14:16:06 +0000 Subject: [PATCH] Optimize Stream: YouTube removal, Latency improvements, Hotel Wi-Fi kit, and Listener sync --- downloader.py | 210 -------------------------------------------- index.html | 30 ------- requirements.txt | 2 - script.js | 158 ++++----------------------------- server.py | 134 +++++++++++++--------------- style.css | 224 ----------------------------------------------- 6 files changed, 78 insertions(+), 680 deletions(-) delete mode 100644 downloader.py diff --git a/downloader.py b/downloader.py deleted file mode 100644 index eb41af7..0000000 --- a/downloader.py +++ /dev/null @@ -1,210 +0,0 @@ -import requests -import os -import re -import shutil - -def clean_filename(title): - # Remove quotes and illegal characters - title = title.strip("'").strip('"') - return re.sub(r'[\\/*?:"<>|]', "", title) - -def _ensure_music_dir(): - if not os.path.exists("music"): - os.makedirs("music") - -def _normalize_quality_kbps(quality): - try: - q = int(str(quality).strip()) - except Exception: - q = 320 - # Clamp to the expected UI values - if q <= 128: - return 128 - if q <= 192: - return 192 - return 320 - -def _can_use_ytdlp(): - # yt-dlp needs ffmpeg to reliably output MP3. - if shutil.which("ffmpeg") is None: - return False - try: - import yt_dlp # noqa: F401 - return True - except Exception: - return False - -def _download_with_ytdlp(url, quality_kbps): - import yt_dlp - - _ensure_music_dir() - - # Force a predictable output location. yt-dlp will sanitize filenames. - outtmpl = os.path.join("music", "%(title)s.%(ext)s") - - ydl_opts = { - "format": "bestaudio/best", - "outtmpl": outtmpl, - "noplaylist": True, - "quiet": True, - "no_warnings": True, - "overwrites": False, - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - } - ], - # Best-effort attempt to honor the UI bitrate selector. - # Note: This depends on ffmpeg, and the source may not have enough fidelity. - "postprocessor_args": ["-b:a", f"{quality_kbps}k"], - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=True) - - # Handle playlist-like responses defensively even though noplaylist=True. - if isinstance(info, dict) and "entries" in info and info["entries"]: - info = info["entries"][0] - - # Compute final mp3 path. - # prepare_filename returns the pre-postprocessed path, so swap extension. - base_path = None - if isinstance(info, dict): - try: - base_path = yt_dlp.YoutubeDL({"outtmpl": outtmpl}).prepare_filename(info) - except Exception: - base_path = None - - if base_path: - mp3_path = os.path.splitext(base_path)[0] + ".mp3" - title = os.path.splitext(os.path.basename(mp3_path))[0] - return {"success": True, "title": title} - - # Fallback return if we couldn't derive paths - return {"success": True, "title": "downloaded"} - -def download_mp3(url, quality='320'): - print(f"\nšŸ” Processing: {url}") - - quality_kbps = _normalize_quality_kbps(quality) - - # Prefer yt-dlp for YouTube because it can actually control MP3 output bitrate. - if _can_use_ytdlp(): - try: - print(f"✨ Using yt-dlp (preferred method)") - print(f"ā¬‡ļø Downloading @ {quality_kbps}kbps...") - return _download_with_ytdlp(url, quality_kbps) - except Exception as e: - # If yt-dlp fails for any reason, fall back to the existing Cobalt flow. - print(f"āš ļø yt-dlp failed, falling back to Cobalt API: {e}") - else: - # Check what's missing - has_ffmpeg = shutil.which("ffmpeg") is not None - has_ytdlp = False - try: - import yt_dlp # noqa: F401 - has_ytdlp = True - except: - pass - - if not has_ffmpeg: - print("āš ļø ffmpeg not found - using Cobalt API fallback") - if not has_ytdlp: - print("āš ļø yt-dlp not installed - using Cobalt API fallback") - print(" šŸ’” Install with: pip install yt-dlp") - - try: - # Use Cobalt v9 API to download - print("🌐 Requesting download from Cobalt API v9...") - - _ensure_music_dir() - - response = requests.post( - 'https://api.cobalt.tools/api/v9/process', - headers={ - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - json={ - 'url': url, - 'downloadMode': 'audio', - 'audioFormat': 'mp3' - }, - timeout=30 - ) - - print(f"šŸ“” API Response Status: {response.status_code}") - - if response.status_code != 200: - try: - error_data = response.json() - print(f"āŒ Cobalt API error: {error_data}") - except: - print(f"āŒ Cobalt API error: {response.text}") - return {"success": False, "error": f"API returned {response.status_code}"} - - data = response.json() - print(f"šŸ“¦ API Response: {data}") - - # Check for errors in response - if data.get('status') == 'error': - error_msg = data.get('text', 'Unknown error') - print(f"āŒ Cobalt error: {error_msg}") - return {"success": False, "error": error_msg} - - # Get download URL - download_url = data.get('url') - if not download_url: - print(f"āŒ No download URL in response: {data}") - return {"success": False, "error": "No download URL received"} - - print(f"šŸ“„ Downloading audio...") - - # Download the audio file - audio_response = requests.get(download_url, stream=True, timeout=60) - - if audio_response.status_code != 200: - print(f"āŒ Download failed: {audio_response.status_code}") - return {"success": False, "error": f"Download failed with status {audio_response.status_code}"} - - # Try to get filename from Content-Disposition header - content_disposition = audio_response.headers.get('Content-Disposition', '') - if 'filename=' in content_disposition: - filename = content_disposition.split('filename=')[1].strip('"') - filename = clean_filename(os.path.splitext(filename)[0]) - else: - # Fallback: extract video ID and use it - video_id = url.split('v=')[-1].split('&')[0] - filename = f"youtube_{video_id}" - - # Ensure .mp3 extension - output_path = f"music/{filename}.mp3" - - # Save the file - with open(output_path, 'wb') as f: - for chunk in audio_response.iter_content(chunk_size=8192): - f.write(chunk) - - print(f"āœ… Success! Saved as: {filename}.mp3") - print(" (Hit Refresh in the App)") - return {"success": True, "title": filename} - - except requests.exceptions.Timeout: - print("āŒ Request timed out") - return {"success": False, "error": "Request timed out"} - except requests.exceptions.RequestException as e: - print(f"āŒ Network error: {e}") - return {"success": False, "error": str(e)} - except Exception as e: - print(f"āŒ Error: {e}") - return {"success": False, "error": str(e)} - -if __name__ == "__main__": - _ensure_music_dir() - - print("--- TECHDJ DOWNLOADER (via Cobalt API) ---") - while True: - url = input("\nšŸ”— URL (q to quit): ").strip() - if url.lower() == 'q': break - if url: download_mp3(url) diff --git a/index.html b/index.html index 505270a..ef8a413 100644 --- a/index.html +++ b/index.html @@ -50,12 +50,6 @@ - -
- -
-
@@ -122,18 +116,6 @@
-
- -
- - -
-
-
@@ -260,18 +242,6 @@
-
- -
- - -
-
-
diff --git a/requirements.txt b/requirements.txt index ef0a4ae..4be8ee7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ # TechDJ Requirements flask flask-socketio -yt-dlp eventlet python-dotenv -requests diff --git a/script.js b/script.js index 15a2ed8..9e048dc 100644 --- a/script.js +++ b/script.js @@ -1156,107 +1156,6 @@ function refreshLibrary() { fetchLibrary(); } -// YouTube Search Functions -let youtubeSearchTimeout = null; - -function handleYouTubeSearch(query) { - // Debounce search - wait 300ms after user stops typing - clearTimeout(youtubeSearchTimeout); - - if (!query || query.trim().length < 2) { - document.getElementById('youtube-results').innerHTML = ''; - return; - } - - youtubeSearchTimeout = setTimeout(() => { - searchYouTube(query.trim()); - }, 300); -} - -async function searchYouTube(query) { - const resultsDiv = document.getElementById('youtube-results'); - resultsDiv.innerHTML = '
šŸ” Searching YouTube...
'; - - try { - const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`); - const data = await response.json(); - - if (!data.success) { - resultsDiv.innerHTML = `
āŒ ${data.error}
`; - return; - } - - displayYouTubeResults(data.results); - } catch (error) { - console.error('YouTube search error:', error); - resultsDiv.innerHTML = '
āŒ Search failed. Check console.
'; - } -} - -function displayYouTubeResults(results) { - const resultsDiv = document.getElementById('youtube-results'); - - if (results.length === 0) { - resultsDiv.innerHTML = '
No results found
'; - return; - } - - resultsDiv.innerHTML = ''; - - results.forEach(result => { - const resultCard = document.createElement('div'); - resultCard.className = 'youtube-result-card'; - - resultCard.innerHTML = ` - ${result.title} -
-
${result.title}
-
${result.channel}
-
- - `; - - resultsDiv.appendChild(resultCard); - }); -} - -async function downloadYouTubeResult(url, title) { - const resultsDiv = document.getElementById('youtube-results'); - const originalContent = resultsDiv.innerHTML; - - resultsDiv.innerHTML = '
ā³ Downloading...
'; - - try { - const response = await fetch('/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: url, quality: '320' }) - }); - - const result = await response.json(); - - if (result.success) { - resultsDiv.innerHTML = '
āœ… Downloaded! Refreshing library...
'; - await fetchLibrary(); - setTimeout(() => { - resultsDiv.innerHTML = originalContent; - }, 2000); - } else { - resultsDiv.innerHTML = `
āŒ Download failed: ${result.error}
`; - setTimeout(() => { - resultsDiv.innerHTML = originalContent; - }, 3000); - } - } catch (error) { - console.error('Download error:', error); - resultsDiv.innerHTML = '
āŒ Download failed
'; - setTimeout(() => { - resultsDiv.innerHTML = originalContent; - }, 3000); - } -} async function loadFromServer(id, url, title) { const d = document.getElementById('display-' + id); @@ -1382,33 +1281,6 @@ async function loadFromServer(id, url, title) { } } -// Download Functionality -async function downloadFromPanel(deckId) { - const input = document.getElementById('search-input-' + deckId); - const statusDiv = document.getElementById('download-status-' + deckId); - const qualitySelect = document.getElementById('quality-' + deckId); - const url = input.value.trim(); - if (!url) return; - - statusDiv.innerHTML = '
Downloading...
'; - try { - const res = await fetch('/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: url, quality: qualitySelect.value }) - }); - const result = await res.json(); - if (result.success) { - statusDiv.innerHTML = 'āœ… Complete!'; - fetchLibrary(); - setTimeout(() => statusDiv.innerHTML = '', 3000); - } else { - statusDiv.innerHTML = 'āŒ Failed'; - } - } catch (e) { - statusDiv.innerHTML = 'āŒ Error'; - } -} // Settings function toggleSettings() { @@ -1645,6 +1517,9 @@ function initSocket() { console.log('āœ… Connected to streaming server'); console.log(` Socket ID: ${socket.id}`); console.log(` Transport: ${socket.io.engine.transport.name}`); + + // Get initial listener count soon as we connect + socket.emit('get_listener_count'); }); socket.on('connect_error', (error) => { @@ -1807,7 +1682,8 @@ function startBroadcast() { document.getElementById('broadcast-status').classList.add('live'); if (!socket) initSocket(); - socket.emit('start_broadcast'); + const bitrateValue = document.getElementById('stream-quality').value + 'k'; + socket.emit('start_broadcast', { bitrate: bitrateValue }); socket.emit('get_listener_count'); console.log('āœ… Server-side broadcast started'); @@ -2010,7 +1886,8 @@ function startBroadcast() { // Notify server that broadcast is active (listeners use MP3 stream) if (!socket) initSocket(); - socket.emit('start_broadcast'); + const bitrateValue = document.getElementById('stream-quality').value + 'k'; + socket.emit('start_broadcast', { bitrate: bitrateValue }); socket.emit('get_listener_count'); console.log('āœ… Broadcasting started successfully!'); @@ -2170,37 +2047,38 @@ function toggleAutoStream(enabled) { function startRemoteRelay() { const urlInput = document.getElementById('remote-stream-url'); const url = urlInput.value.trim(); - + if (!url) { alert('Please enter a remote stream URL'); return; } - + if (!socket) initSocket(); - + // Stop any existing broadcast first if (isBroadcasting) { stopBroadcast(); } - + console.log('šŸ”— Starting remote relay for:', url); - + // Update UI document.getElementById('start-relay-btn').style.display = 'none'; document.getElementById('stop-relay-btn').style.display = 'inline-block'; document.getElementById('relay-status').textContent = 'Connecting to remote stream...'; document.getElementById('relay-status').style.color = '#00f3ff'; - - socket.emit('start_remote_relay', { url: url }); + + const bitrateValue = document.getElementById('stream-quality').value + 'k'; + socket.emit('start_remote_relay', { url: url, bitrate: bitrateValue }); } function stopRemoteRelay() { if (!socket) return; - + console.log('šŸ›‘ Stopping remote relay'); - + socket.emit('stop_remote_relay'); - + // Update UI document.getElementById('start-relay-btn').style.display = 'inline-block'; document.getElementById('stop-relay-btn').style.display = 'none'; diff --git a/server.py b/server.py index bb1c009..abd2a8a 100644 --- a/server.py +++ b/server.py @@ -8,12 +8,13 @@ import subprocess import threading import queue import time +import collections from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context from flask_socketio import SocketIO, emit from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() -import downloader + def _load_config(): @@ -46,7 +47,8 @@ dj_sids = set() # This allows listeners on browsers that don't support WebM/Opus via MediaSource # (notably some Safari / locked-down environments) to still hear the stream. _ffmpeg_proc = None -_ffmpeg_in_q = queue.Queue(maxsize=200) +_ffmpeg_in_q = queue.Queue(maxsize=40) +_current_bitrate = "192k" _mp3_clients = set() # set[queue.Queue] _mp3_lock = threading.Lock() _transcode_threads_started = False @@ -54,6 +56,7 @@ _transcoder_bytes_out = 0 _transcoder_last_error = None _last_audio_chunk_ts = 0.0 _remote_stream_url = None # For relaying remote streams +_mp3_preroll = collections.deque(maxlen=60) # Pre-roll (~2.5s at 192k) def _start_transcoder_if_needed(): @@ -71,7 +74,9 @@ def _start_transcoder_if_needed(): '-i', _remote_stream_url, '-vn', '-acodec', 'libmp3lame', - '-b:a', '192k', + '-b:a', _current_bitrate, + '-tune', 'zerolatency', + '-flush_packets', '1', '-f', 'mp3', 'pipe:1', ] @@ -84,7 +89,9 @@ def _start_transcoder_if_needed(): '-i', 'pipe:0', '-vn', '-acodec', 'libmp3lame', - '-b:a', '192k', + '-b:a', _current_bitrate, + '-tune', 'zerolatency', + '-flush_packets', '1', '-f', 'mp3', 'pipe:1', ] @@ -135,15 +142,19 @@ def _start_transcoder_if_needed(): return while True: try: - data = proc.stdout.read(4096) + data = proc.stdout.read(1024) except Exception: _transcoder_last_error = 'stdout read failed' break if not data: break _transcoder_bytes_out += len(data) + + # Store in pre-roll with _mp3_lock: + _mp3_preroll.append(data) clients = list(_mp3_clients) + for q in clients: try: q.put_nowait(data) @@ -158,7 +169,7 @@ def _start_transcoder_if_needed(): def _stop_transcoder(): - global _ffmpeg_proc + global _ffmpeg_proc, _transcode_threads_started try: _ffmpeg_in_q.put_nowait(None) except Exception: @@ -166,6 +177,12 @@ def _stop_transcoder(): proc = _ffmpeg_proc _ffmpeg_proc = None + + # Reset thread flag so they can be re-launched if needed + # (The existing threads will exit cleanly on None/EOF) + _transcode_threads_started = False + _mp3_preroll.clear() + if proc is None: return try: @@ -203,67 +220,6 @@ def setup_shared_routes(app): }) return jsonify(library) - @app.route('/download', methods=['POST']) - def download(): - data = request.get_json(silent=True) or {} - url = data.get('url') - quality = data.get('quality', '320') - if not url: - return jsonify({"success": False, "error": "No URL provided"}), 400 - result = downloader.download_mp3(url, quality) - return jsonify(result) - - @app.route('/search_youtube', methods=['GET']) - def search_youtube(): - query = request.args.get('q', '') - if not query: - return jsonify({"success": False, "error": "No query provided"}), 400 - - # Get API key from environment variable - api_key = os.environ.get('YOUTUBE_API_KEY', '') - if not api_key: - return jsonify({ - "success": False, - "error": "YouTube API key not configured. Set YOUTUBE_API_KEY environment variable." - }), 500 - - try: - import requests - # Search YouTube using Data API v3 - url = 'https://www.googleapis.com/youtube/v3/search' - params = { - 'part': 'snippet', - 'q': query, - 'type': 'video', - 'videoCategoryId': '10', # Music category - 'maxResults': 20, - 'key': api_key - } - - response = requests.get(url, params=params) - data = response.json() - - if 'error' in data: - return jsonify({ - "success": False, - "error": data['error'].get('message', 'YouTube API error') - }), 400 - - # Format results - results = [] - for item in data.get('items', []): - results.append({ - 'videoId': item['id']['videoId'], - 'title': item['snippet']['title'], - 'channel': item['snippet']['channelTitle'], - 'thumbnail': item['snippet']['thumbnails']['medium']['url'], - 'url': f"https://www.youtube.com/watch?v={item['id']['videoId']}" - }) - - return jsonify({"success": True, "results": results}) - - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 @app.route('/') def serve_static(filename): @@ -313,8 +269,14 @@ def setup_shared_routes(app): if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: return jsonify({"success": False, "error": "MP3 stream not available"}), 503 - client_q: queue.Queue = queue.Queue(maxsize=200) + client_q: queue.Queue = queue.Queue(maxsize=20) with _mp3_lock: + # Burst pre-roll to new client so they start playing instantly + for chunk in _mp3_preroll: + try: + client_q.put_nowait(chunk) + except Exception: + break _mp3_clients.add(client_q) def gen(): @@ -488,11 +450,20 @@ def dj_start(data=None): session['is_dj'] = True print("šŸŽ™ļø Broadcast -> ACTIVE") + if data and 'bitrate' in data: + global _current_bitrate + _current_bitrate = data['bitrate'] + print(f"šŸ“” Setting stream bitrate to: {_current_bitrate}") + _start_transcoder_if_needed() listener_socketio.emit('broadcast_started', namespace='/') listener_socketio.emit('stream_status', {'active': True}, namespace='/') +@dj_socketio.on('get_listener_count') +def dj_get_listener_count(): + emit('listener_count', {'count': len(listener_sids)}) + @dj_socketio.on('stop_broadcast') def dj_stop(): broadcast_state['active'] = False @@ -516,10 +487,16 @@ def dj_start_remote_relay(data): if broadcast_state['active']: dj_stop() + global _remote_stream_url, _current_bitrate _remote_stream_url = url broadcast_state['active'] = True broadcast_state['remote_relay'] = True session['is_dj'] = True + + if data and 'bitrate' in data: + _current_bitrate = data['bitrate'] + print(f"šŸ“” Setting relay bitrate to: {_current_bitrate}") + print(f"šŸ”— Starting remote relay from: {url}") _start_transcoder_if_needed() @@ -573,12 +550,12 @@ def listener_connect(): @listener_socketio.on('disconnect') def listener_disconnect(): - if request.sid in listener_sids: - listener_sids.discard(request.sid) - count = len(listener_sids) - print(f"āŒ Listener left. Total: {count}") - listener_socketio.emit('listener_count', {'count': count}, namespace='/') - dj_socketio.emit('listener_count', {'count': count}, namespace='/') + listener_sids.discard(request.sid) + count = len(listener_sids) + print(f"āŒ Listener left. Total: {count}") + # Notify BOTH namespaces + listener_socketio.emit('listener_count', {'count': count}, namespace='/') + dj_socketio.emit('listener_count', {'count': count}, namespace='/') @listener_socketio.on('join_listener') def listener_join(): @@ -604,6 +581,14 @@ def get_mixer_status(): def audio_sync_queue(data): pass +def _listener_count_sync_loop(): + """Periodic background sync to ensure listener count is always accurate.""" + while True: + count = len(listener_sids) + listener_socketio.emit('listener_count', {'count': count}, namespace='/') + dj_socketio.emit('listener_count', {'count': count}, namespace='/') + eventlet.sleep(5) + if __name__ == '__main__': print("=" * 50) @@ -617,5 +602,6 @@ if __name__ == '__main__': print("āœ… Local Radio server ready") # Run both servers using eventlet's spawn + eventlet.spawn(_listener_count_sync_loop) eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=5000, debug=False) listener_socketio.run(listener_app, host='0.0.0.0', port=5001, debug=False) diff --git a/style.css b/style.css index 6c68af9..08f8ee6 100644 --- a/style.css +++ b/style.css @@ -240,144 +240,6 @@ header h1 { } } -/* YouTube Search */ -.youtube-search-container { - padding: 10px 15px; - background: rgba(0, 0, 0, 0.3); - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -.youtube-search-container input { - width: 100%; - padding: 10px; - background: rgba(0, 0, 0, 0.5); - border: 1px solid var(--primary-cyan); - color: #fff; - border-radius: 4px; - font-family: 'Rajdhani', sans-serif; - font-size: 0.9rem; - box-shadow: 0 0 10px rgba(0, 243, 255, 0.2); - transition: all 0.3s; -} - -.youtube-search-container input:focus { - outline: none; - border-color: var(--primary-cyan); - box-shadow: 0 0 15px rgba(0, 243, 255, 0.4); -} - -.youtube-results { - max-height: 300px; - overflow-y: auto; - margin-top: 10px; -} - -.youtube-result-card { - display: flex; - align-items: center; - gap: 10px; - padding: 8px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 4px; - margin-bottom: 6px; - transition: all 0.2s; - cursor: pointer; -} - -.youtube-result-card:hover { - background: rgba(255, 255, 255, 0.08); - border-color: var(--primary-cyan); - transform: translateX(2px); -} - -.result-thumbnail { - width: 80px; - height: 60px; - object-fit: cover; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -.result-info { - flex: 1; - min-width: 0; -} - -.result-title { - font-size: 0.85rem; - font-weight: bold; - color: #fff; - margin-bottom: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.result-channel { - font-size: 0.75rem; - color: #888; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.result-download-btn { - padding: 8px 12px; - background: rgba(0, 243, 255, 0.2); - border: 1px solid var(--primary-cyan); - color: var(--primary-cyan); - border-radius: 4px; - cursor: pointer; - font-size: 1.2rem; - transition: all 0.2s; - flex-shrink: 0; -} - -.result-download-btn:hover { - background: rgba(0, 243, 255, 0.3); - box-shadow: 0 0 10px rgba(0, 243, 255, 0.4); - transform: scale(1.1); -} - -.result-download-btn:active { - transform: scale(0.95); -} - -.search-loading, -.search-error, -.search-success, -.search-empty { - padding: 15px; - text-align: center; - border-radius: 4px; - font-family: 'Orbitron', sans-serif; - font-size: 0.9rem; -} - -.search-loading { - background: rgba(255, 187, 0, 0.1); - color: #ffbb00; - border: 1px solid rgba(255, 187, 0, 0.3); -} - -.search-error { - background: rgba(255, 68, 68, 0.1); - color: #ff4444; - border: 1px solid rgba(255, 68, 68, 0.3); -} - -.search-success { - background: rgba(0, 255, 0, 0.1); - color: #00ff00; - border: 1px solid rgba(0, 255, 0, 0.3); -} - -.search-empty { - background: rgba(255, 255, 255, 0.03); - color: #666; - border: 1px solid rgba(255, 255, 255, 0.05); -} .library-list { flex: 1; @@ -612,92 +474,6 @@ canvas { border: 1px solid #333; } -/* SEARCH & DOWNLOAD */ -.start-group { - position: relative; - margin-bottom: 4px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.search-input { - width: 100%; - padding: 6px 8px; - background: #111; - border: 1px solid #444; - color: #fff; - font-family: 'Rajdhani', sans-serif; - font-size: 0.85rem; - border-radius: 4px; - box-sizing: border-box; -} - -.download-btn { - width: 100%; - padding: 6px; - background: var(--primary-cyan); - border: 1px solid var(--primary-cyan); - font-weight: bold; - cursor: pointer; - font-size: 0.8rem; - font-family: 'Orbitron', sans-serif; - color: #000; - border-radius: 4px; - transition: all 0.3s; - box-shadow: 0 0 10px rgba(0, 243, 255, 0.2); -} - -.download-btn:hover { - box-shadow: 0 0 20px rgba(0, 243, 255, 0.5); - transform: translateY(-1px); -} - -.download-btn:active { - transform: translateY(0); -} - -#deck-B .download-btn { - background: var(--secondary-magenta); - border-color: var(--secondary-magenta); - color: #fff; - box-shadow: 0 0 10px rgba(188, 19, 254, 0.2); -} - -#deck-B .download-btn:hover { - box-shadow: 0 0 20px rgba(188, 19, 254, 0.5); -} - - -.quality-selector { - background: rgba(10, 10, 12, 0.8); - color: var(--text-color); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 4px 6px; - border-radius: 4px; - font-family: 'Rajdhani', sans-serif; - font-size: 0.8rem; - outline: none; - cursor: pointer; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -.download-status .loading { - color: #ffa500; - animation: pulse 1.5s ease-in-out infinite; -} - -.download-status .success { - color: #00ff00; - font-weight: bold; -} - -.download-status .error { - color: #ff4444; - font-weight: bold; -} @keyframes pulse {