Instruction file imported from luftEinz/ytdlpwebui (
.github/instructions/webui.instructions.md). Copyright stays with the author.
make a yt-dlp.exe webui with playlists, or single videos and options for best audio + video and add the available Options as click boxes? For Audio and Video or Type of audio like W AV or MP3 And also. Use FFmpeg and add the available Options as click boxes? For Audio and Video or Type of audio like W AV or MP3 And also. Use FFmpeg and add the available Options as click boxes? For Audio and Video or Type of audio like W AV or MP3 And also. Use FFmpeg to upscale audio and video. Print out the directory tree of this project and also output every code. For each file in a code Block without any explanation at all, you don't have to explain anything just code. and the tree of the directory and the command that makes that directory in PowerShell or bash
project-structure/
├── app.py
├── static/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── script.js
├── templates/
│ └── index.html
├── downloads/
├── config/
├── requirements.txt
└── run.bat
PowerShell command to create directory structure:
mkdir project-structure; cd project-structure; mkdir static\css, static\js, templates, downloads, config
Bash command to create directory structure:
mkdir -p project-structure/{static/{css,js},templates,downloads,config} && cd project-structure
app.py
from flask import Flask, render_template, request, jsonify, send_file
import yt_dlp
import subprocess
import os
import json
import threading
from datetime import datetime
import uuid
app = Flask(__name__)
# Global variables to track downloads
downloads = {}
download_status = {}
class DownloadLogger:
def __init__(self, download_id):
self.download_id = download_id
def debug(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
download_status[self.download_id]['status'] = 'error'
download_status[self.download_id]['error'] = msg
def progress_hook(d, download_id):
if d['status'] == 'downloading':
download_status[download_id]['status'] = 'downloading'
download_status[download_id]['progress'] = d.get('_percent_str', '0%')
download_status[download_id]['speed'] = d.get('_speed_str', 'N/A')
elif d['status'] == 'finished':
download_status[download_id]['status'] = 'finished'
download_status[download_id]['progress'] = '100%'
download_status[download_id]['filename'] = d['filename']
def download_video(url, options, download_id):
try:
download_status[download_id] = {
'status': 'starting',
'progress': '0%',
'speed': 'N/A',
'error': None,
'filename': None
}
ydl_opts = {
'outtmpl': 'downloads/%(title)s.%(ext)s',
'logger': DownloadLogger(download_id),
'progress_hooks': [lambda d: progress_hook(d, download_id)],
}
# Video quality options
if options['quality'] == 'best':
ydl_opts['format'] = 'bestvideo+bestaudio/best'
elif options['quality'] == 'worst':
ydl_opts['format'] = 'worst'
elif options['quality'] == 'audio_only':
ydl_opts['format'] = 'bestaudio'
ydl_opts['postprocessors'] = [{
'key': 'FFmpegExtractAudio',
'preferredcodec': options['audio_format'],
'preferredquality': options['audio_quality'],
}]
elif options['quality'] == 'custom':
format_string = options['custom_format']
ydl_opts['format'] = format_string
# Video processing options
if options.get('video_processing'):
postprocessors = ydl_opts.get('postprocessors', [])
if options.get('upscale_video'):
postprocessors.append({
'key': 'FFmpegVideoConvertor',
'preferedformat': options['video_format'],
})
if options.get('normalize_audio'):
postprocessors.append({
'key': 'FFmpegAudioFix',
})
ydl_opts['postprocessors'] = postprocessors
# Subtitle options
if options.get('download_subtitles'):
ydl_opts['writesubtitles'] = True
ydl_opts['writeautomaticsub'] = True
ydl_opts['subtitleslangs'] = ['en']
# Thumbnail options
if options.get('download_thumbnail'):
ydl_opts['writethumbnail'] = True
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# Post-process with FFmpeg if needed
if options.get('ffmpeg_processing'):
apply_ffmpeg_processing(download_id, options)
except Exception as e:
download_status[download_id]['status'] = 'error'
download_status[download_id]['error'] = str(e)
def apply_ffmpeg_processing(download_id, options):
try:
filename = download_status[download_id]['filename']
if not filename:
return
base_name = os.path.splitext(filename)[^1_0]
output_file = f"{base_name}_processed.mp4"
cmd = ['ffmpeg', '-i', filename]
# Video filters
video_filters = []
if options.get('upscale_video'):
scale_factor = options.get('upscale_factor', '2')
if scale_factor == 'custom':
resolution = options.get('custom_resolution', '1920x1080')
video_filters.append(f'scale={resolution}:flags=lanczos')
else:
video_filters.append(f'scale=iw*{scale_factor}:ih*{scale_factor}:flags=lanczos')
if options.get('denoise_video'):
video_filters.append('hqdn3d')
if options.get('sharpen_video'):
video_filters.append('unsharp=5:5:1.0:5:5:0.0')
if video_filters:
cmd.extend(['-vf', ','.join(video_filters)])
# Audio filters
audio_filters = []
if options.get('normalize_audio'):
audio_filters.append('loudnorm')
if options.get('enhance_audio'):
audio_filters.append('highpass=f=200,lowpass=f=3000')
if audio_filters:
cmd.extend(['-af', ','.join(audio_filters)])
# Codec options
if options.get('video_codec'):
cmd.extend(['-c:v', options['video_codec']])
if options.get('audio_codec'):
cmd.extend(['-c:a', options['audio_codec']])
# Quality options
if options.get('crf'):
cmd.extend(['-crf', str(options['crf'])])
if options.get('audio_bitrate'):
cmd.extend(['-b:a', options['audio_bitrate']])
cmd.append(output_file)
download_status[download_id]['status'] = 'processing'
subprocess.run(cmd, check=True)
download_status[download_id]['filename'] = output_file
download_status[download_id]['status'] = 'finished'
except Exception as e:
download_status[download_id]['status'] = 'error'
download_status[download_id]['error'] = f"FFmpeg processing failed: {str(e)}"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/download', methods=['POST'])
def start_download():
data = request.json
url = data['url']
options = data['options']
download_id = str(uuid.uuid4())
thread = threading.Thread(target=download_video, args=(url, options, download_id))
thread.start()
return jsonify({'download_id': download_id})
@app.route('/status/<download_id>')
def get_status(download_id):
status = download_status.get(download_id, {'status': 'not_found'})
return jsonify(status)
@app.route('/downloads')
def list_downloads():
files = []
download_dir = 'downloads'
if os.path.exists(download_dir):
for file in os.listdir(download_dir):
file_path = os.path.join(download_dir, file)
if os.path.isfile(file_path):
files.append({
'name': file,
'size': os.path.getsize(file_path),
'modified': datetime.fromtimestamp(os.path.getmtime(file_path)).isoformat()
})
return jsonify(files)
@app.route('/download_file/<filename>')
def download_file(filename):
return send_file(os.path.join('downloads', filename), as_attachment=True)
if __name__ == '__main__':
os.makedirs('downloads', exist_ok=True)
os.makedirs('config', exist_ok=True)
app.run(debug=True, host='0.0.0.0', port=5000)
templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YT-DLP Web UI</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="container">
<h1>YT-DLP Web UI</h1>
<div class="form-section">
<h2>Download Video/Audio</h2>
<form id="downloadForm">
<div class="input-group">
<label for="url">URL (Video or Playlist):</label>
<input type="text" id="url" placeholder="https://www.youtube.com/watch?v=..." required>
</div>
<div class="options-grid">
<div class="option-group">
<h3>Quality Options</h3>
<div class="radio-group">
<input type="radio" id="best" name="quality" value="best" checked>
<label for="best">Best Video + Audio</label>
</div>
<div class="radio-group">
<input type="radio" id="worst" name="quality" value="worst">
<label for="worst">Worst Quality</label>
</div>
<div class="radio-group">
<input type="radio" id="audio_only" name="quality" value="audio_only">
<label for="audio_only">Audio Only</label>
</div>
<div class="radio-group">
<input type="radio" id="custom" name="quality" value="custom">
<label for="custom">Custom Format</label>
</div>
<input type="text" id="custom_format" placeholder="bestvideo[height<=720]+bestaudio" disabled>
</div>
<div class="option-group">
<h3>Audio Options</h3>
<div class="checkbox-group">
<label for="audio_format">Audio Format:</label>
<select id="audio_format">
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="aac">AAC</option>
<option value="flac">FLAC</option>
<option value="opus">OPUS</option>
<option value="m4a">M4A</option>
</select>
</div>
<div class="checkbox-group">
<label for="audio_quality">Audio Quality:</label>
<select id="audio_quality">
<option value="0">Best</option>
<option value="128">128k</option>
<option value="192">192k</option>
<option value="256">256k</option>
<option value="320">320k</option>
</select>
</div>
</div>
<div class="option-group">
<h3>Video Options</h3>
<div class="checkbox-group">
<label for="video_format">Video Format:</label>
<select id="video_format">
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
<option value="avi">AVI</option>
<option value="mov">MOV</option>
</select>
</div>
<div class="checkbox-group">
<input type="checkbox" id="download_subtitles">
<label for="download_subtitles">Download Subtitles</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="download_thumbnail">
<label for="download_thumbnail">Download Thumbnail</label>
</div>
</div>
<div class="option-group">
<h3>FFmpeg Processing</h3>
<div class="checkbox-group">
<input type="checkbox" id="ffmpeg_processing">
<label for="ffmpeg_processing">Enable FFmpeg Processing</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="upscale_video">
<label for="upscale_video">Upscale Video</label>
</div>
<div class="checkbox-group">
<label for="upscale_factor">Upscale Factor:</label>
<select id="upscale_factor">
<option value="2">2x</option>
<option value="4">4x</option>
<option value="custom">Custom Resolution</option>
</select>
</div>
<input type="text" id="custom_resolution" placeholder="1920x1080" disabled>
<div class="checkbox-group">
<input type="checkbox" id="normalize_audio">
<label for="normalize_audio">Normalize Audio</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="enhance_audio">
<label for="enhance_audio">Enhance Audio</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="denoise_video">
<label for="denoise_video">Denoise Video</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="sharpen_video">
<label for="sharpen_video">Sharpen Video</label>
</div>
</div>
<div class="option-group">
<h3>Advanced Options</h3>
<div class="checkbox-group">
<label for="video_codec">Video Codec:</label>
<select id="video_codec">
<option value="">Default</option>
<option value="libx264">H.264</option>
<option value="libx265">H.265</option>
<option value="libvpx-vp9">VP9</option>
<option value="libaom-av1">AV1</option>
</select>
</div>
<div class="checkbox-group">
<label for="audio_codec">Audio Codec:</label>
<select id="audio_codec">
<option value="">Default</option>
<option value="aac">AAC</option>
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
<option value="flac">FLAC</option>
</select>
</div>
<div class="checkbox-group">
<label for="crf">CRF (Quality):</label>
<input type="range" id="crf" min="0" max="51" value="23">
<span id="crf_value">23</span>
</div>
<div class="checkbox-group">
<label for="audio_bitrate">Audio Bitrate:</label>
<select id="audio_bitrate">
<option value="">Default</option>
<option value="128k">128k</option>
<option value="192k">192k</option>
<option value="256k">256k</option>
<option value="320k">320k</option>
<option value="512k">512k</option>
</select>
</div>
</div>
</div>
<button type="submit">Start Download</button>
</form>
</div>
<div class="status-section">
<h2>Download Status</h2>
<div id="downloadStatus"></div>
</div>
<div class="files-section">
<h2>Downloaded Files</h2>
<button id="refreshFiles">Refresh File List</button>
<div id="fileList"></div>
</div>
</div>
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
static/css/style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: #333;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
color: white;
margin-bottom: 30px;
font-size: 2.5em;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
h2 {
color: #2c3e50;
margin-bottom: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h3 {
color: #34495e;
margin-bottom: 15px;
font-size: 1.2em;
}
.form-section, .status-section, .files-section {
background: white;
border-radius: 10px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #2c3e50;
}
.input-group input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.input-group input[type="text"]:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 25px;
margin-bottom: 25px;
}
.option-group {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.radio-group, .checkbox-group {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.radio-group input[type="radio"],
.checkbox-group input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.2);
}
.radio-group label,
.checkbox-group label {
margin-bottom: 0;
cursor: pointer;
font-weight: normal;
}
select, input[type="range"] {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 120px;
}
input[type="range"] {
width: 100px;
}
#crf_value {
font-weight: bold;
color: #3498db;
margin-left: 10px;
}
button {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
padding: 12px 30px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(52, 152, 219, 0.3);
}
button:hover {
background: linear-gradient(135deg, #2980b9, #1f5f8b);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(52, 152, 219, 0.4);
}
button:active {
transform: translateY(0);
}
#downloadStatus {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
min-height: 50px;
}
.status-item {
background: white;
border-left: 4px solid #3498db;
padding: 15px;
margin-bottom: 10px;
border-radius: 0 6px 6px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.status-downloading {
border-left-color: #f39c12;
}
.status-finished {
border-left-color: #27ae60;
}
.status-error {
border-left-color: #e74c3c;
}
.file-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s ease;
}
.file-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
.file-info {
flex-grow: 1;
}
.file-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 5px;
}
.file-meta {
font-size: 12px;
color: #7f8c8d;
}
.download-btn {
background: #27ae60;
padding: 8px 16px;
font-size: 14px;
}
.download-btn:hover {
background: #229954;
}
.progress-bar {
width: 100%;
height: 20px;
background: #ecf0f1;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3498db, #2980b9);
transition: width 0.3s ease;
border-radius: 10px;
}
input:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
}
@media (max-width: 768px) {
.options-grid {
grid-template-columns: 1fr;
}
.container {
padding: 10px;
}
h1 {
font-size: 2em;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
static/js/script.js
let currentDownloads = {};
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('downloadForm');
const customRadio = document.getElementById('custom');
const customFormat = document.getElementById('custom_format');
const upscaleVideo = document.getElementById('upscale_video');
const upscaleFactor = document.getElementById('upscale_factor');
const customResolution = document.getElementById('custom_resolution');
const crfSlider = document.getElementById('crf');
const crfValue = document.getElementById('crf_value');
const refreshBtn = document.getElementById('refreshFiles');
const ffmpegProcessing = document.getElementById('ffmpeg_processing');
// Handle custom format input
document.querySelectorAll('input[name="quality"]').forEach(radio => {
radio.addEventListener('change', function() {
customFormat.disabled = this.value !== 'custom';
});
});
// Handle upscale factor
upscaleFactor.addEventListener('change', function() {
customResolution.disabled = this.value !== 'custom';
});
// Handle CRF slider
crfSlider.addEventListener('input', function() {
crfValue.textContent = this.value;
});
// Handle FFmpeg processing toggle
ffmpegProcessing.addEventListener('change', function() {
const ffmpegOptions = document.querySelectorAll('#upscale_video, #normalize_audio, #enhance_audio, #denoise_video, #sharpen_video');
ffmpegOptions.forEach(option => {
option.disabled = !this.checked;
});
});
// Form submission
form.addEventListener('submit', function(e) {
e.preventDefault();
const url = document.getElementById('url').value;
const quality = document.querySelector('input[name="quality"]:checked').value;
const options = {
quality: quality,
audio_format: document.getElementById('audio_format').value,
audio_quality: document.getElementById('audio_quality').value,
video_format: document.getElementById('video_format').value,
download_subtitles: document.getElementById('download_subtitles').checked,
download_thumbnail: document.getElementById('download_thumbnail').checked,
ffmpeg_processing: document.getElementById('ffmpeg_processing').checked,
upscale_video: document.getElementById('upscale_video').checked,
upscale_factor: document.getElementById('upscale_factor').value,
custom_resolution: document.getElementById('custom_resolution').value,
normalize_audio: document.getElementById('normalize_audio').checked,
enhance_audio: document.getElementById('enhance_audio').checked,
denoise_video: document.getElementById('denoise_video').checked,
sharpen_video: document.getElementById('sharpen_video').checked,
video_codec: document.getElementById('video_codec').value,
audio_codec: document.getElementById('audio_codec').value,
crf: document.getElementById('crf').value,
audio_bitrate: document.getElementById('audio_bitrate').value
};
if (quality === 'custom') {
options.custom_format = document.getElementById('custom_format').value;
}
startDownload(url, options);
});
// Refresh files button
refreshBtn.addEventListener('click', loadFileList);
// Initialize
loadFileList();
// Initial FFmpeg options state
ffmpegProcessing.dispatchEvent(new Event('change'));
});
function startDownload(url, options) {
fetch('/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url,
options: options
})
})
.then(response => response.json())
.then(data => {
const downloadId = data.download_id;
currentDownloads[downloadId] = {
url: url,
options: options,
startTime: new Date()
};
monitorDownload(downloadId);
})
.catch(error => {
console.error('Error:', error);
showStatus('Error starting download: ' + error.message, 'error');
});
}
function monitorDownload(downloadId) {
const interval = setInterval(() => {
fetch(`/status/${downloadId}`)
.then(response => response.json())
.then(status => {
updateDownloadStatus(downloadId, status);
if (status.status === 'finished' || status.status === 'error') {
clearInterval(interval);
if (status.status === 'finished') {
loadFileList();
}
}
})
.catch(error => {
console.error('Error checking status:', error);
clearInterval(interval);
});
}, 1000);
}
function updateDownloadStatus(downloadId, status) {
const statusDiv = document.getElementById('downloadStatus');
let statusHtml = '';
for (const [id, downloadStatus] of Object.entries(currentDownloads)) {
if (id === downloadId) {
const download = currentDownloads[id];
const statusClass = `status-${status.status}`;
statusHtml += `
<div class="status-item ${statusClass}">
<div><strong>URL:</strong> ${download.url}</div>
<div><strong>Status:</strong> ${status.status}</div>
<div><strong>Progress:</strong> ${status.progress || 'N/A'}</div>
<div><strong>Speed:</strong> ${status.speed || 'N/A'}</div>
${status.error ? `<div><strong>Error:</strong> ${status.error}</div>` : ''}
${status.filename ? `<div><strong>File:</strong> ${status.filename}</div>` : ''}
${status.progress && status.progress !== '100%' ?
`<div class="progress-bar">
<div class="progress-fill" style="width: ${status.progress}"></div>
</div>` : ''
}
</div>
`;
}
}
statusDiv.innerHTML = statusHtml || '<p>No active downloads</p>';
}
function loadFileList() {
fetch('/downloads')
.then(response => response.json())
.then(files => {
const fileList = document.getElementById('fileList');
if (files.length === 0) {
fileList.innerHTML = '<p>No files downloaded yet</p>';
return;
}
const fileHtml = files.map(file => `
<div class="file-item">
<div class="file-info">
<div class="file-name">${file.name}</div>
<div class="file-meta">
Size: ${formatFileSize(file.size)} |
Modified: ${new Date(file.modified).toLocaleString()}
</div>
</div>
<button class="download-btn" onclick="downloadFile('${file.name}')">
Download
</button>
</div>
`).join('');
fileList.innerHTML = fileHtml;
})
.catch(error => {
console.error('Error loading files:', error);
});
}
function downloadFile(filename) {
window.open(`/download_file/${encodeURIComponent(filename)}`, '_blank');
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('downloadStatus');
statusDiv.innerHTML = `<div class="status-item status-${type}">${message}</div>`;
}
requirements.txt
Flask==2.3.3
yt-dlp==2023.9.24
Werkzeug==2.3.7
run.bat
@echo off
echo Starting YT-DLP Web UI...
echo.
echo Installing dependencies...
pip install -r requirements.txt
echo.
echo Starting Flask server...
python app.py
pause
add a framework to enhance the UI, give it a kanagawa.nvim type colorscheme
I'll enhance the YT-DLP Web UI with a modern CSS framework and implement a Kanagawa-inspired color scheme. I'll use a combination of custom CSS with Kanagawa colors and integrate a lightweight CSS framework for better components.
Updated Project Structure
project-structure/
├── app.py
├── static/
│ ├── css/
│ │ ├── kanagawa.css
│ │ ├── framework.css
│ │ └── style.css
│ ├── js/
│ │ ├── framework.js
│ │ └── script.js
│ └── fonts/
├── templates/
│ └── index.html
├── downloads/
├── config/
├── requirements.txt
└── run.bat
static/css/kanagawa.css
:root {
/* Kanagawa Color Palette */
--fuji-white: #DCD7BA;
--old-white: #C8C093;
--sumi-ink-0: #16161D;
--sumi-ink-1: #1F1F28;
--sumi-ink-2: #2A2A37;
--sumi-ink-3: #363646;
--sumi-ink-4: #54546D;
--wave-blue-1: #223249;
--wave-blue-2: #2D4F67;
--wave-red: #E46876;
--peach-red: #FF5D62;
--surimi-orange: #FFA066;
--spring-violet-1: #938AA9;
--oni-violet: #957FB8;
--crystal-blue: #7E9CD8;
--spring-blue: #7FB4CA;
--light-blue: #A3D4D5;
--wave-aqua-1: #6A9589;
--wave-aqua-2: #7AA89F;
--spring-green: #98BB6C;
--boat-yellow-1: #938056;
--boat-yellow-2: #C0A36E;
--carp-yellow: #E6C384;
--sakura-pink: #D27E99;
--wave-red-2: #E98A00;
--peach-red-2: #FF9E3B;
--autumn-green: #76946A;
--autumn-red: #C34043;
--autumn-yellow: #DCA561;
--ronin-yellow: #FF9E64;
--dragon-blue: #658594;
--katana-gray: #717C7C;
/* Semantic Colors */
--bg-primary: var(--sumi-ink-1);
--bg-secondary: var(--sumi-ink-2);
--bg-tertiary: var(--sumi-ink-3);
--bg-float: var(--sumi-ink-0);
--bg-accent: var(--wave-blue-1);
--bg-accent-hover: var(--wave-blue-2);
--text-primary: var(--fuji-white);
--text-secondary: var(--old-white);
--text-muted: var(--sumi-ink-4);
--text-accent: var(--crystal-blue);
--border-primary: var(--sumi-ink-4);
--border-accent: var(--crystal-blue);
--accent-primary: var(--crystal-blue);
--accent-secondary: var(--spring-violet-1);
--accent-success: var(--spring-green);
--accent-warning: var(--boat-yellow-2);
--accent-error: var(--wave-red);
--accent-info: var(--light-blue);
/* Shadows */
--shadow-sm: 0 2px 4px rgba(22, 22, 29, 0.3);
--shadow-md: 0 4px 12px rgba(22, 22, 29, 0.4);
--shadow-lg: 0 8px 25px rgba(22, 22, 29, 0.5);
--shadow-xl: 0 12px 40px rgba(22, 22, 29, 0.6);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
}
/* Light theme variant */
[data-theme="light"] {
--bg-primary: #F2ECBC;
--bg-secondary: #E7DDB7;
--bg-tertiary: #DDD8BB;
--bg-float: #FFFFFF;
--bg-accent: #D5CEA3;
--bg-accent-hover: #C9C299;
--text-primary: var(--sumi-ink-1);
--text-secondary: var(--sumi-ink-2);
--text-muted: var(--sumi-ink-4);
--text-accent: var(--dragon-blue);
--border-primary: #C8C093;
--border-accent: var(--dragon-blue);
--shadow-sm: 0 2px 4px rgba(31, 31, 40, 0.1);
--shadow-md: 0 4px 12px rgba(31, 31, 40, 0.15);
--shadow-lg: 0 8px 25px rgba(31, 31, 40, 0.2);
--shadow-xl: 0 12px 40px rgba(31, 31, 40, 0.25);
}
/* Base styles */
* {
box-sizing: border-box;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.6;
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--sumi-ink-4);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Selection styling */
::selection {
background: var(--wave-blue-2);
color: var(--text-primary);
}
static/css/framework.css
/* Modern CSS Framework Components */
/* Container System */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-lg);
}
.container-fluid {
width: 100%;
padding: 0 var(--space-lg);
}
/* Grid System */
.grid {
display: grid;
gap: var(--space-lg);
}
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.col-span-1 { grid-column: span 1; }
.col-span-2 { grid-column: span 2; }
.col-span-3 { grid-column: span 3; }
.col-span-4 { grid-column: span 4; }
/* Flexbox Utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
/* Card Component */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-md);
transition: all var(--transition-normal);
backdrop-filter: blur(10px);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-header {
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-lg);
}
.card-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.card-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin: var(--space-xs) 0 0 0;
}
/* Button Components */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.btn:before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left var(--transition-normal);
}
.btn:hover:before {
left: 100%;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-primary), var(--crystal-blue));
color: var(--bg-primary);
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--spring-blue), var(--accent-primary));
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-accent);
border-color: var(--border-accent);
}
.btn-success {
background: linear-gradient(135deg, var(--accent-success), var(--autumn-green));
color: var(--bg-primary);
}
.btn-warning {
background: linear-gradient(135deg, var(--accent-warning), var(--ronin-yellow));
color: var(--bg-primary);
}
.btn-error {
background: linear-gradient(135deg, var(--accent-error), var(--autumn-red));
color: var(--text-primary);
}
.btn-sm {
padding: var(--space-xs) var(--space-md);
font-size: 0.75rem;
}
.btn-lg {
padding: var(--space-md) var(--space-xl);
font-size: 1rem;
}
/* Form Components */
.form-group {
margin-bottom: var(--space-lg);
}
.form-label {
display: block;
margin-bottom: var(--space-sm);
font-weight: 500;
color: var(--text-primary);
}
.form-input {
width: 100%;
padding: var(--space-md);
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
transition: all var(--transition-fast);
}
.form-input:focus {
outline: none;
border-color: var(--border-accent);
box-shadow: 0 0 0 3px rgba(126, 156, 216, 0.1);
background: var(--bg-secondary);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23717C7C' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: var(--space-2xl);
}
/* Checkbox and Radio Components */
.form-check {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.form-check-input {
width: 1.125rem;
height: 1.125rem;
margin: 0;
cursor: pointer;
}
.form-check-input[type="checkbox"] {
border-radius: var(--radius-sm);
}
.form-check-input[type="radio"] {
border-radius: 50%;
}
.form-check-label {
margin: 0;
cursor: pointer;
user-select: none;
}
/* Progress Component */
.progress {
width: 100%;
height: 0.5rem;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin: var(--space-md) 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--spring-blue));
border-radius: var(--radius-sm);
transition: width var(--transition-normal);
position: relative;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
animation: progress-stripes 1s linear infinite;
}
@keyframes progress-stripes {
0% { background-position: 1rem 0; }
100% { background-position: 0 0; }
}
/* Badge Component */
.badge {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-primary { background: var(--accent-primary); color: var(--bg-primary); }
.badge-success { background: var(--accent-success); color: var(--bg-primary); }
.badge-warning { background: var(--accent-warning); color: var(--bg-primary); }
.badge-error { background: var(--accent-error); color: var(--text-primary); }
.badge-info { background: var(--accent-info); color: var(--bg-primary); }
/* Alert Component */
.alert {
padding: var(--space-lg);
border-radius: var(--radius-md);
border-left: 4px solid;
margin-bottom: var(--space-lg);
}
.alert-success {
background: rgba(152, 187, 108, 0.1);
border-color: var(--accent-success);
color: var(--accent-success);
}
.alert-warning {
background: rgba(192, 163, 110, 0.1);
border-color: var(--accent-warning);
color: var(--accent-warning);
}
.alert-error {
background: rgba(228, 104, 118, 0.1);
border-color: var(--accent-error);
color: var(--accent-error);
}
.alert-info {
background: rgba(163, 212, 213, 0.1);
border-color: var(--accent-info);
color: var(--accent-info);
}
/* Utility Classes */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }
.text-accent { color: var(--text-accent); }
.bg-primary { background: var(--bg-primary); }
.bg-secondary { background: var(--bg-secondary); }
.bg-tertiary { background: var(--bg-tertiary); }
.rounded { border-radius: var(--radius-md); }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.shadow { box-shadow: var(--shadow-md); }
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
.m-0 { margin: 0; }
.mt-0 { margin-top: 0; }
.mb-0 { margin-bottom: 0; }
.ml-0 { margin-left: 0; }
.mr-0 { margin-right: 0; }
.p-0 { padding: 0; }
.pt-0 { padding-top: 0; }
.pb-0 { padding-bottom: 0; }
.pl-0 { padding-left: 0; }
.pr-0 { padding-right: 0; }
/* Responsive Design */
@media (max-width: 768px) {
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
.container {
padding: 0 var(--space-md);
}
.card {
padding: var(--space-lg);
}
}
static/css/style.css
/* Enhanced YT-DLP Web UI Styles */
body {
min-height: 100vh;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Header */
.header {
text-align: center;
padding: var(--space-2xl) 0;
background: linear-gradient(135deg, var(--sumi-ink-0), var(--sumi-ink-2));
margin-bottom: var(--space-2xl);
border-bottom: 1px solid var(--border-primary);
}
.header h1 {
font-size: 3rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--spring-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
text-shadow: none;
}
.header p {
color: var(--text-secondary);
font-size: 1.125rem;
margin: var(--space-md) 0 0 0;
}
/* Theme Toggle */
.theme-toggle {
position: fixed;
top: var(--space-lg);
right: var(--space-lg);
z-index: 1000;
}
.theme-toggle button {
background: var(--bg-float);
border: 1px solid var(--border-primary);
border-radius: 50%;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-normal);
box-shadow: var(--shadow-md);
}
.theme-toggle button:hover {
background: var(--bg-accent);
transform: scale(1.1);
}
/* Form Sections */
.form-section {
background: var(--bg-secondary);
backdrop-filter: blur(20px);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
margin-bottom: var(--space-2xl);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.form-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-primary), var(--spring-blue), var(--spring-violet-1));
}
.section-title {
color: var(--text-primary);
font-size: 1.75rem;
font-weight: 600;
margin-bottom: var(--space-xl);
display: flex;
align-items: center;
gap: var(--space-md);
}
.section-title::before {
content: '';
width: 4px;
height: 2rem;
background: linear-gradient(135deg, var(--accent-primary), var(--spring-blue));
border-radius: var(--radius-sm);
}
/* Options Grid */
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
.option-group {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
transition: all var(--transition-normal);
}
.option-group:hover {
border-color: var(--border-accent);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.option-group h3 {
color: var(--text-accent);
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.option-group h3::before {
content: '▶';
color: var(--accent-primary);
font-size: 0.875rem;
}
/* Custom Form Controls */
.radio-group,
.checkbox-group {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
padding: var(--space-sm);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
.radio-group:hover,
.checkbox-group:hover {
background: rgba(126, 156, 216, 0.05);
}
.radio-group input[type="radio"],
.checkbox-group input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin: 0;
accent-color: var(--accent-primary);
}
.radio-group label,
.checkbox-group label {
margin: 0;
cursor: pointer;
user-select: none;
font-weight: 500;
flex: 1;
}
/* Range Slider */
.range-group {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
input[type="range"] {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
}
input[type="range"]::-webkit-slider-thumb:hover {
background: var(--spring-blue);
transform: scale(1.1);
}
.range-value {
background: var(--accent-primary);
color: var(--bg-primary);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-weight: 600;
min-width: 3rem;
text-align: center;
}
/* Status Section */
.status-section {
background: var(--bg-float);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
margin-bottom: var(--space-2xl);
box-shadow: var(--shadow-lg);
}
.status-item {
background: var(--bg-secondary);
border-left: 4px solid var(--accent-primary);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-normal);
}
.status-item:hover {
box-shadow: var(--shadow-md);
transform: translateX(4px);
}
.status-downloading {
border-left-color: var(--accent-warning);
background: rgba(192, 163, 110, 0.05);
}
.status-finished {
border-left-color: var(--accent-success);
background: rgba(152, 187, 108, 0.05);
}
.status-error {
border-left-color: var(--accent-error);
background: rgba(228, 104, 118, 0.05);
}
.status-processing {
border-left-color: var(--spring-violet-1);
background: rgba(147, 138, 169, 0.05);
}
/* File List */
.file-item {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin-bottom: var(--space-md);
display: flex;
justify-content: space-between;
align-items: center;
transition: all var(--transition-normal);
}
.file-item:hover {
border-color: var(--border-accent);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
font-size: 1rem;
}
.file-meta {
font-size: 0.875rem;
color: var(--text-secondary);
display: flex;
gap: var(--space-md);
}
.file-meta span {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Loading Animation */
.loading {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--bg-tertiary);
border-radius: 50%;
border-top-color: var(--accent-primary);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Pulse Animation */
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.header h1 {
font-size: 2rem;
}
.options-grid {
grid-template-columns: 1fr;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: var(--space-md);
}
.file-meta {
flex-direction: column;
gap: var(--space-xs);
}
.theme-toggle {
top: var(--space-md);
right: var(--space-md);
}
}
/* Dark/Light theme animations */
* {
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
color var(--transition-normal);
}
static/js/framework.js
// Modern CSS Framework JavaScript Components
class KanagawaUI {
constructor() {
this.theme = localStorage.getItem('theme') || 'dark';
this.init();
}
init() {
this.setTheme(this.theme);
this.setupThemeToggle();
this.setupFormEnhancements();
this.setupAnimations();
}
setTheme(theme) {
this.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Update theme toggle icon
const themeToggle = document.querySelector('.theme-toggle button');
if (themeToggle) {
themeToggle.innerHTML = theme === 'dark' ? '☀️' : '🌙';
}
}
setupThemeToggle() {
// Create theme toggle if it doesn't exist
if (!document.querySelector('.theme-toggle')) {
const toggle = document.createElement('div');
toggle.className = 'theme-toggle';
toggle.innerHTML = `
<button type="button" aria-label="Toggle theme">
${this.theme === 'dark' ? '☀️' : '🌙'}
</button>
`;
document.body.appendChild(toggle);
}
// Add event listener
document.querySelector('.theme-toggle button').addEventListener('click', () => {
this.setTheme(this.theme === 'dark' ? 'light' : 'dark');
});
}
setupFormEnhancements() {
// Auto-resize textareas
document.querySelectorAll('textarea').forEach(textarea => {
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
});
// Enhanced form validation
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('blur', () => {
this.validateField(input);
});
});
// Floating labels
document.querySelectorAll('.form-group').forEach(group => {
const input = group.querySelector('.form-input');
const label = group.querySelector('.form-label');
if (input && label) {
input.addEventListener('focus', () => {
label.style.transform = 'translateY(-1.5rem) scale(0.875)';
label.style.color = 'var(--accent-primary)';
});
input.addEventListener('blur', () => {
if (!input.value) {
label.style.transform = '';
label.style.color = '';
}
});
}
});
}
validateField(field) {
const value = field.value.trim();
const type = field.type;
let isValid = true;
let message = '';
// Remove existing validation classes
field.classList.remove('is-valid', 'is-invalid');
// Basic validation rules
if (field.required && !value) {
isValid = false;
message = 'This field is required';
} else if (type === 'email' && value && !this.isValidEmail(value)) {
isValid = false;
message = 'Please enter a valid email address';
} else if (type === 'url' && value && !this.isValidUrl(value)) {
isValid = false;
message = 'Please enter a valid URL';
}
// Apply validation classes
field.classList.add(isValid ? 'is-valid' : 'is-invalid');
// Show/hide validation message
let feedback = field.parentNode.querySelector('.form-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'form-feedback';
field.parentNode.appendChild(feedback);
}
feedback.textContent = message;
feedback.className = `form-feedback ${isValid ? 'valid-feedback' : 'invalid-feedback'}`;
return isValid;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
setupAnimations() {
// Intersection Observer for fade-in animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.opacity = '1';
entry.target.style.transform = 'translateY(0)';
}
});
}, { threshold: 0.1 });
// Observe all cards and form sections
document.querySelectorAll('.card, .form-section, .option-group').forEach(el => {
el.style.opacity = '0';
el.style.transform = 'translateY(20px)';
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
observer.observe(el);
});
// Stagger animations
document.querySelectorAll('.option-group').forEach((el, index) => {
el.style.transitionDelay =
*Truncated - read the full file at https://github.com/luftEinz/ytdlpwebui/blob/48732f30d18ac557e8caef99f6ba8d9f7039a88b/.github/instructions/webui.instructions.md.