Initial commit
This commit is contained in:
519
templates/index.html
Normal file
519
templates/index.html
Normal file
@@ -0,0 +1,519 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Votify Web</title>
|
||||
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg: #121212; --surface: #1e1e1e; --surface2: #2a2a2a;
|
||||
--accent: #1db954; --accent-hover: #1ed760;
|
||||
--text: #ffffff; --text2: #b3b3b3;
|
||||
--danger: #e74c3c; --warning: #f39c12;
|
||||
--radius: 8px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
.header { background: var(--surface); padding: 16px 24px; display: flex; align-items: center; gap: 16px; border-bottom: 1px solid var(--surface2); }
|
||||
.header h1 { font-size: 1.4rem; font-weight: 700; }
|
||||
.header h1 span { color: var(--accent); }
|
||||
|
||||
.tabs { display: flex; gap: 4px; margin-left: auto; }
|
||||
.tab { background: none; border: none; color: var(--text2); padding: 8px 16px; cursor: pointer; border-radius: var(--radius); font-size: 0.9rem; transition: all 0.2s; }
|
||||
.tab:hover { color: var(--text); background: var(--surface2); }
|
||||
.tab.active { color: var(--accent); background: var(--surface2); }
|
||||
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
.card { background: var(--surface); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
|
||||
.card h2 { font-size: 1.1rem; margin-bottom: 16px; color: var(--text2); }
|
||||
|
||||
label { display: block; font-size: 0.85rem; color: var(--text2); margin-bottom: 6px; }
|
||||
textarea, select, input[type="number"] {
|
||||
width: 100%; background: var(--surface2); border: 1px solid #333; color: var(--text);
|
||||
border-radius: var(--radius); padding: 10px 12px; font-size: 0.9rem; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
|
||||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.9rem; color: var(--text2); }
|
||||
.checkbox-label input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
|
||||
.btn { background: var(--accent); color: #000; border: none; padding: 10px 24px; border-radius: 20px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
||||
.btn:hover { background: var(--accent-hover); transform: scale(1.02); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.8rem; }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-secondary { background: var(--surface2); color: var(--text); }
|
||||
|
||||
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
|
||||
.status-queued { background: #333; color: var(--text2); }
|
||||
.status-running { background: #1a3a2a; color: var(--accent); }
|
||||
.status-completed { background: #1a3a1a; color: #4caf50; }
|
||||
.status-failed { background: #3a1a1a; color: var(--danger); }
|
||||
|
||||
.job-card { background: var(--surface); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
|
||||
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.job-urls { font-size: 0.8rem; color: var(--text2); word-break: break-all; margin-bottom: 8px; }
|
||||
.job-progress { height: 4px; background: var(--surface2); border-radius: 2px; margin-bottom: 8px; overflow: hidden; }
|
||||
.job-progress-bar { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s ease; }
|
||||
.job-toggle { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 0.78rem; padding: 4px 0; display: flex; align-items: center; gap: 6px; font-family: inherit; transition: color 0.15s; }
|
||||
.job-toggle:hover { color: var(--text); }
|
||||
.job-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; }
|
||||
.job-toggle .arrow.open { transform: rotate(90deg); }
|
||||
.job-preview { color: var(--text2); opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 500px; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.job-output-wrapper { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.job-output-wrapper.open { max-height: 300px; overflow-y: auto; }
|
||||
.job-output { background: #0d0d0d; border-radius: 4px; padding: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; word-break: break-all; }
|
||||
.job-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
.file-list { list-style: none; }
|
||||
.file-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius); transition: background 0.15s; }
|
||||
.file-item:hover { background: var(--surface2); }
|
||||
.file-icon { font-size: 1.2rem; width: 24px; text-align: center; }
|
||||
.file-name { flex: 1; font-size: 0.9rem; cursor: pointer; }
|
||||
.file-size { font-size: 0.8rem; color: var(--text2); }
|
||||
.file-actions { display: flex; gap: 4px; }
|
||||
.file-actions .btn-icon { background: none; border: none; color: var(--text2); cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 0.85rem; transition: all 0.15s; }
|
||||
.file-actions .btn-icon:hover { background: var(--surface2); color: var(--text); }
|
||||
.file-actions .btn-icon.delete:hover { color: var(--danger); }
|
||||
.breadcrumb { display: flex; gap: 4px; align-items: center; margin-bottom: 16px; font-size: 0.85rem; flex-wrap: wrap; }
|
||||
.breadcrumb a { color: var(--accent); text-decoration: none; }
|
||||
.breadcrumb span { color: var(--text2); }
|
||||
|
||||
.cookie-status { display: flex; align-items: center; gap: 12px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.dot-green { background: var(--accent); }
|
||||
.dot-red { background: var(--danger); }
|
||||
|
||||
.empty { text-align: center; padding: 40px; color: var(--text2); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.header { flex-wrap: wrap; }
|
||||
.tabs { margin-left: 0; width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 style="cursor:pointer" onclick="showPage('download')"><span>Votify</span> Web</h1>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showPage('download')">Download</button>
|
||||
<button class="tab" onclick="showPage('jobs')">Jobs</button>
|
||||
<button class="tab" onclick="showPage('files')">Files</button>
|
||||
<button class="tab" onclick="showPage('settings')">Settings</button>
|
||||
{% if auth_enabled %}<a href="/logout" class="tab" style="text-decoration:none">Logout</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- DOWNLOAD PAGE -->
|
||||
<div id="page-download" class="page active">
|
||||
<div class="card">
|
||||
<h2>New Download</h2>
|
||||
<div class="form-group">
|
||||
<label for="urls">Spotify URLs (one per line)</label>
|
||||
<textarea id="urls" placeholder="https://open.spotify.com/track/... https://open.spotify.com/album/..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label for="audio_quality">Audio Quality</label>
|
||||
<select id="audio_quality">
|
||||
<option value="aac-medium">AAC 128kbps</option>
|
||||
<option value="aac-high">AAC 256kbps (Premium)</option>
|
||||
<option value="vorbis-low">Vorbis 96kbps</option>
|
||||
<option value="vorbis-medium">Vorbis 160kbps</option>
|
||||
<option value="vorbis-high">Vorbis 320kbps (Premium)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="output_format">Output Format</label>
|
||||
<select id="output_format">
|
||||
<option value="original">Keep Original</option>
|
||||
<option value="mp3" selected>MP3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="video_format">Video Format</label>
|
||||
<select id="video_format">
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cover_size">Cover Size</label>
|
||||
<select id="cover_size">
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large" selected>Large</option>
|
||||
<option value="extra-large">Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="download_mode">Download Mode</label>
|
||||
<select id="download_mode">
|
||||
<option value="ytdlp">yt-dlp</option>
|
||||
<option value="aria2c">aria2c</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="save_cover"> Save Cover Art</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="save_playlist"> Save Playlist File</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="overwrite"> Overwrite Existing</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="download_music_videos"> Download Music Videos</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="no_lrc"> No Lyrics</label>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="btn-download" onclick="startDownload()">Start Download</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOBS PAGE -->
|
||||
<div id="page-jobs" class="page">
|
||||
<div class="card">
|
||||
<h2>Download Jobs</h2>
|
||||
<div id="jobs-list"><div class="empty">No jobs yet</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FILES PAGE -->
|
||||
<div id="page-files" class="page">
|
||||
<div class="card">
|
||||
<h2>Downloaded Files</h2>
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
<ul class="file-list" id="file-list"><div class="empty">No files yet</div></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS PAGE -->
|
||||
<div id="page-settings" class="page">
|
||||
<div class="card">
|
||||
<h2>Cookies</h2>
|
||||
<div class="cookie-status" id="cookie-status">
|
||||
<div class="dot dot-red"></div>
|
||||
<span>Checking...</span>
|
||||
</div>
|
||||
<br>
|
||||
<label>Upload cookies.txt (Netscape format)</label>
|
||||
<input type="file" id="cookie-file" accept=".txt" style="margin-top:8px">
|
||||
<br><br>
|
||||
<button class="btn btn-sm btn-secondary" onclick="uploadCookies()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPath = "";
|
||||
let jobPollInterval = null;
|
||||
const expandedJobs = new Set();
|
||||
|
||||
function showPage(name) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('page-' + name).classList.add('active');
|
||||
const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`);
|
||||
if (matchingTab) matchingTab.classList.add('active');
|
||||
|
||||
if (name === 'jobs') loadJobs();
|
||||
if (name === 'files') loadFiles("");
|
||||
if (name === 'settings') checkCookies();
|
||||
|
||||
if (jobPollInterval) clearInterval(jobPollInterval);
|
||||
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const btn = document.getElementById('btn-download');
|
||||
btn.disabled = true;
|
||||
|
||||
const data = {
|
||||
urls: document.getElementById('urls').value,
|
||||
audio_quality: document.getElementById('audio_quality').value,
|
||||
download_mode: document.getElementById('download_mode').value,
|
||||
video_format: document.getElementById('video_format').value,
|
||||
cover_size: document.getElementById('cover_size').value,
|
||||
save_cover: document.getElementById('save_cover').checked,
|
||||
save_playlist: document.getElementById('save_playlist').checked,
|
||||
overwrite: document.getElementById('overwrite').checked,
|
||||
download_music_videos: document.getElementById('download_music_videos').checked,
|
||||
no_lrc: document.getElementById('no_lrc').checked,
|
||||
output_format: document.getElementById('output_format').value,
|
||||
};
|
||||
|
||||
saveSettings();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok) {
|
||||
document.getElementById('urls').value = '';
|
||||
document.querySelector('[onclick="showPage(\'jobs\')"]').click();
|
||||
} else {
|
||||
alert(result.error || 'Failed to start download');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function parseProgress(output) {
|
||||
if (!output || output.length === 0) return null;
|
||||
let current = 0, total = 0, dlPct = 0;
|
||||
for (const line of output) {
|
||||
const tm = line.match(/Track (\d+)\/(\d+)/);
|
||||
if (tm) { current = parseInt(tm[1]); total = parseInt(tm[2]); }
|
||||
const dm = line.match(/\[download\]\s+([\d.]+)%/);
|
||||
if (dm) dlPct = parseFloat(dm[1]);
|
||||
}
|
||||
if (total <= 0) return null;
|
||||
// Combine track progress with current download percentage
|
||||
const pct = ((current - 1 + dlPct / 100) / total) * 100;
|
||||
return { current, total, pct: Math.min(Math.round(pct), 100) };
|
||||
}
|
||||
|
||||
function toggleJobLog(jobId, currentlyOpen) {
|
||||
if (currentlyOpen) expandedJobs.delete(jobId);
|
||||
else expandedJobs.add(jobId);
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
let loadJobsInFlight = false;
|
||||
async function loadJobs() {
|
||||
if (loadJobsInFlight) return;
|
||||
loadJobsInFlight = true;
|
||||
try {
|
||||
const res = await fetch('/api/jobs');
|
||||
const jobs = await res.json();
|
||||
const container = document.getElementById('jobs-list');
|
||||
|
||||
if (jobs.length === 0) {
|
||||
container.innerHTML = '<div class="empty">No jobs yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = jobs.slice().reverse().map(job => {
|
||||
const hasOutput = job.output && job.output.length > 0;
|
||||
const isOpen = expandedJobs.has(job.id);
|
||||
const lastLine = hasOutput ? job.output[job.output.length - 1] : '';
|
||||
const progress = job.status === 'running' ? parseProgress(job.output) : null;
|
||||
|
||||
let progressHtml = '';
|
||||
if (progress) {
|
||||
progressHtml = `<div class="job-progress"><div class="job-progress-bar" style="width:${progress.pct}%"></div></div>`;
|
||||
}
|
||||
|
||||
let logHtml = '';
|
||||
if (hasOutput) {
|
||||
logHtml = `<button class="job-toggle" onclick="toggleJobLog('${job.id}', ${isOpen})">
|
||||
<span class="arrow ${isOpen ? 'open' : ''}">▶</span>
|
||||
${isOpen ? 'Hide Log' : 'Show Log'}
|
||||
${!isOpen && lastLine ? `<span class="job-preview">${escapeHtml(lastLine)}</span>` : ''}
|
||||
</button>
|
||||
<div class="job-output-wrapper ${isOpen ? 'open' : ''}">
|
||||
<div class="job-output">${escapeHtml(job.output.join('\n'))}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="job-card">
|
||||
<div class="job-header">
|
||||
<strong>Job ${job.id}</strong>
|
||||
<span class="status-badge status-${job.status}">${job.status}</span>
|
||||
</div>
|
||||
<div class="job-urls">${job.urls.join(', ')}</div>
|
||||
${progressHtml}
|
||||
${logHtml}
|
||||
<div class="job-actions">
|
||||
${job.status !== 'running' ? `<button class="btn btn-sm btn-danger" onclick="deleteJob('${job.id}')">Remove</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
|
||||
// Auto-scroll open outputs
|
||||
container.querySelectorAll('.job-output-wrapper.open .job-output').forEach(el => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load jobs', e);
|
||||
} finally {
|
||||
loadJobsInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJob(id) {
|
||||
expandedJobs.delete(id);
|
||||
await fetch('/api/jobs/' + id, { method: 'DELETE' });
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
async function loadFiles(path) {
|
||||
currentPath = path;
|
||||
try {
|
||||
const res = await fetch('/api/files?path=' + encodeURIComponent(path));
|
||||
const files = await res.json();
|
||||
|
||||
// Breadcrumb
|
||||
const bc = document.getElementById('breadcrumb');
|
||||
const parts = path ? path.split('/') : [];
|
||||
let bcHtml = '<a href="#" onclick="loadFiles(\'\');return false">Root</a>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += (accumulated ? '/' : '') + part;
|
||||
bcHtml += ` <span>/</span> <a href="#" onclick="loadFiles(this.dataset.p);return false" data-p="${escapeAttr(accumulated)}">${escapeHtml(part)}</a>`;
|
||||
}
|
||||
bc.innerHTML = bcHtml;
|
||||
|
||||
// File list
|
||||
const list = document.getElementById('file-list');
|
||||
if (files.length === 0) {
|
||||
list.innerHTML = '<div class="empty">No files yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = files.map(f => {
|
||||
const safePath = escapeAttr(f.path);
|
||||
const encodedPath = encodeURIComponent(f.path);
|
||||
if (f.is_dir) {
|
||||
return `<li class="file-item">
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
|
||||
<div class="file-actions">
|
||||
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">⬇</button>
|
||||
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
} else {
|
||||
const size = formatSize(f.size);
|
||||
return `<li class="file-item">
|
||||
<span class="file-icon">🎵</span>
|
||||
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
|
||||
<span class="file-size">${size}</span>
|
||||
<div class="file-actions">
|
||||
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, false)" data-p="${safePath}" title="Delete file">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load files', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePath(path, isDir) {
|
||||
const kind = isDir ? 'folder and all its contents' : 'file';
|
||||
if (!confirm(`Are you sure you want to delete this ${kind}?\n\n${path}`)) return;
|
||||
try {
|
||||
const res = await fetch('/api/files/delete?path=' + encodeURIComponent(path), { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadFiles(currentPath);
|
||||
} else {
|
||||
let msg = 'Delete failed';
|
||||
try { const data = await res.json(); msg = data.error || msg; } catch (_) {}
|
||||
alert(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCookies() {
|
||||
try {
|
||||
const res = await fetch('/api/cookies');
|
||||
const data = await res.json();
|
||||
const el = document.getElementById('cookie-status');
|
||||
if (data.exists) {
|
||||
el.innerHTML = '<div class="dot dot-green"></div><span>cookies.txt found</span>';
|
||||
} else {
|
||||
el.innerHTML = '<div class="dot dot-red"></div><span>cookies.txt not found - upload or mount to /config/cookies.txt</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCookies() {
|
||||
const file = document.getElementById('cookie-file').files[0];
|
||||
if (!file) { alert('Select a file first'); return; }
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/cookies', { method: 'POST', body: form });
|
||||
if (res.ok) {
|
||||
alert('Cookies uploaded successfully');
|
||||
checkCookies();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Upload failed');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||
return bytes.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// Remember settings
|
||||
const SETTINGS_KEY = 'votify-settings';
|
||||
const SETTING_IDS = ['audio_quality', 'output_format', 'video_format', 'cover_size', 'download_mode', 'save_cover', 'save_playlist', 'overwrite', 'download_music_videos', 'no_lrc'];
|
||||
|
||||
function saveSettings() {
|
||||
const settings = {};
|
||||
for (const id of SETTING_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
settings[id] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
}
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY));
|
||||
if (!saved) return;
|
||||
for (const id of SETTING_IDS) {
|
||||
if (!(id in saved)) continue;
|
||||
const el = document.getElementById(id);
|
||||
if (el.type === 'checkbox') el.checked = saved[id];
|
||||
else el.value = saved[id];
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
checkCookies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user