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 @@
-
-
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.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 {