Optimize Stream: YouTube removal, Latency improvements, Hotel Wi-Fi kit, and Listener sync

This commit is contained in:
ComputerTech 2026-01-18 14:16:06 +00:00
parent 8ab422a7aa
commit 508b93125d
6 changed files with 78 additions and 680 deletions

View File

@ -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)

View File

@ -50,12 +50,6 @@
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">🔄</button>
</div>
<!-- YouTube Search -->
<div class="youtube-search-container">
<input type="text" id="youtube-search" placeholder="🎵 SEARCH YOUTUBE..."
onkeyup="handleYouTubeSearch(this.value)">
<div id="youtube-results" class="youtube-results"></div>
</div>
<div id="library-list" class="library-list">
<!-- Tracks go here -->
@ -122,18 +116,6 @@
</div>
</div>
<div class="start-group">
<input type="text" class="search-input" id="search-input-A" placeholder="PASTE YOUTUBE URL...">
<div class="download-controls">
<select id="quality-A" class="quality-selector">
<option value="128">128kbps</option>
<option value="192">192kbps</option>
<option value="320" selected>320kbps</option>
</select>
<button class="download-btn" onclick="downloadFromPanel('A')">⬇ DOWNLOAD</button>
</div>
<div id="download-status-A" class="download-status"></div>
</div>
<div class="controls-grid">
<!-- Volume Fader -->
@ -260,18 +242,6 @@
</div>
</div>
<div class="start-group">
<input type="text" class="search-input" id="search-input-B" placeholder="PASTE YOUTUBE URL...">
<div class="download-controls">
<select id="quality-B" class="quality-selector">
<option value="128">128kbps</option>
<option value="192">192kbps</option>
<option value="320" selected>320kbps</option>
</select>
<button class="download-btn" onclick="downloadFromPanel('B')">⬇ DOWNLOAD</button>
</div>
<div id="download-status-B" class="download-status"></div>
</div>
<div class="controls-grid">

View File

@ -1,8 +1,6 @@
# TechDJ Requirements
flask
flask-socketio
yt-dlp
eventlet
python-dotenv
requests

158
script.js
View File

@ -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 = '<div class="search-loading">🔍 Searching YouTube...</div>';
try {
const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (!data.success) {
resultsDiv.innerHTML = `<div class="search-error">❌ ${data.error}</div>`;
return;
}
displayYouTubeResults(data.results);
} catch (error) {
console.error('YouTube search error:', error);
resultsDiv.innerHTML = '<div class="search-error">❌ Search failed. Check console.</div>';
}
}
function displayYouTubeResults(results) {
const resultsDiv = document.getElementById('youtube-results');
if (results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty">No results found</div>';
return;
}
resultsDiv.innerHTML = '';
results.forEach(result => {
const resultCard = document.createElement('div');
resultCard.className = 'youtube-result-card';
resultCard.innerHTML = `
<img src="${result.thumbnail}" alt="${result.title}" class="result-thumbnail">
<div class="result-info">
<div class="result-title">${result.title}</div>
<div class="result-channel">${result.channel}</div>
</div>
<button class="result-download-btn" onclick="downloadYouTubeResult('${result.url}', '${result.title.replace(/'/g, "\\'")}')">
</button>
`;
resultsDiv.appendChild(resultCard);
});
}
async function downloadYouTubeResult(url, title) {
const resultsDiv = document.getElementById('youtube-results');
const originalContent = resultsDiv.innerHTML;
resultsDiv.innerHTML = '<div class="search-loading">⏳ Downloading...</div>';
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 = '<div class="search-success">✅ Downloaded! Refreshing library...</div>';
await fetchLibrary();
setTimeout(() => {
resultsDiv.innerHTML = originalContent;
}, 2000);
} else {
resultsDiv.innerHTML = `<div class="search-error">❌ Download failed: ${result.error}</div>`;
setTimeout(() => {
resultsDiv.innerHTML = originalContent;
}, 3000);
}
} catch (error) {
console.error('Download error:', error);
resultsDiv.innerHTML = '<div class="search-error">❌ Download failed</div>';
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 = '<div class="progress-text">Downloading...</div>';
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';

134
server.py
View File

@ -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('/<path:filename>')
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)

224
style.css
View File

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