feat(soundboard): download modal with filename input + fix yt-dlp binary
- Add download modal: filename input, progress phases (input/downloading/done/error) - Refactor backend: shared handleUrlDownload() with optional custom filename + rename - Fix Dockerfile: use yt-dlp_linux standalone binary (no Python dependency) - Modal shows URL type badge (YouTube/Instagram/MP3), spinner, retry on error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b2ba0ef86
commit
9ff8a38547
4 changed files with 332 additions and 76 deletions
|
|
@ -32,7 +32,7 @@ FROM node:24-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data
|
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
|
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o /usr/local/bin/yt-dlp \
|
||||||
&& chmod a+rx /usr/local/bin/yt-dlp \
|
&& chmod a+rx /usr/local/bin/yt-dlp \
|
||||||
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg
|
COPY --from=ffmpeg-fetch /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg
|
||||||
|
|
|
||||||
|
|
@ -958,42 +958,66 @@ const soundboardPlugin: Plugin = {
|
||||||
} catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); }
|
} catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Shared download logic for play-url and download-url */
|
||||||
|
async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> {
|
||||||
|
let savedFile: string;
|
||||||
|
let savedPath: string;
|
||||||
|
|
||||||
|
if (isYtDlpUrl(url)) {
|
||||||
|
console.log(`${SB} [url-dl] → yt-dlp...`);
|
||||||
|
const result = await downloadWithYtDlp(url);
|
||||||
|
savedFile = result.filename;
|
||||||
|
savedPath = result.filepath;
|
||||||
|
} else {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
savedFile = path.basename(parsed.pathname);
|
||||||
|
savedPath = path.join(SOUNDS_DIR, savedFile);
|
||||||
|
console.log(`${SB} [url-dl] → direct MP3: ${savedFile}`);
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`);
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
fs.writeFileSync(savedPath, buf);
|
||||||
|
console.log(`${SB} [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename if custom filename provided
|
||||||
|
if (customFilename) {
|
||||||
|
const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
|
||||||
|
if (safeName) {
|
||||||
|
const ext = path.extname(savedFile).toLowerCase() || '.mp3';
|
||||||
|
const newName = safeName.endsWith(ext) ? safeName : safeName + ext;
|
||||||
|
const newPath = path.join(SOUNDS_DIR, newName);
|
||||||
|
if (newPath !== savedPath && !fs.existsSync(newPath)) {
|
||||||
|
fs.renameSync(savedPath, newPath);
|
||||||
|
console.log(`${SB} [url-dl] renamed: ${savedFile} → ${newName}`);
|
||||||
|
savedFile = newName;
|
||||||
|
savedPath = newPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NORMALIZE_ENABLE) {
|
||||||
|
try { await normalizeToCache(savedPath); console.log(`${SB} [url-dl] normalized`); }
|
||||||
|
catch (e: any) { console.error(`${SB} [url-dl] normalize failed: ${e?.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { savedFile, savedPath };
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/soundboard/play-url', async (req, res) => {
|
app.post('/api/soundboard/play-url', async (req, res) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const { url, guildId, channelId, volume } = req.body ?? {};
|
const { url, guildId, channelId, volume, filename } = req.body ?? {};
|
||||||
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
||||||
console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} guild=${guildId} channel=${channelId}`);
|
console.log(`${SB} [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`);
|
||||||
|
|
||||||
if (!url || !guildId || !channelId) { console.log(`${SB} [play-url] ✗ missing params`); res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
|
if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; }
|
||||||
try { new URL(url); } catch { console.log(`${SB} [play-url] ✗ invalid URL`); res.status(400).json({ error: 'Ungültige URL' }); return; }
|
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||||||
if (!isSupportedUrl(url)) { console.log(`${SB} [play-url] ✗ unsupported URL type`); res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
||||||
|
|
||||||
let savedFile: string;
|
const { savedFile, savedPath } = await handleUrlDownload(url, filename);
|
||||||
let savedPath: string;
|
|
||||||
|
|
||||||
if (isYtDlpUrl(url)) {
|
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing`); }
|
||||||
console.log(`${SB} [play-url] → delegating to yt-dlp...`);
|
|
||||||
const result = await downloadWithYtDlp(url);
|
|
||||||
savedFile = result.filename;
|
|
||||||
savedPath = result.filepath;
|
|
||||||
} else {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
savedFile = path.basename(parsed.pathname);
|
|
||||||
savedPath = path.join(SOUNDS_DIR, savedFile);
|
|
||||||
console.log(`${SB} [play-url] → direct MP3 download: ${savedFile}`);
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (!r.ok) {
|
|
||||||
console.error(`${SB} [play-url] ✗ HTTP ${r.status} ${r.statusText}`);
|
|
||||||
res.status(400).json({ error: `Download fehlgeschlagen (HTTP ${r.status})` }); return;
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await r.arrayBuffer());
|
|
||||||
fs.writeFileSync(savedPath, buf);
|
|
||||||
console.log(`${SB} [play-url] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); console.log(`${SB} [play-url] normalized`); } catch (e: any) { console.error(`${SB} [play-url] normalize failed: ${e?.message}`); } }
|
|
||||||
try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`${SB} [play-url] playing in channel`); }
|
|
||||||
catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); }
|
catch (e: any) { console.error(`${SB} [play-url] play failed (file saved): ${e?.message}`); }
|
||||||
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
|
@ -1006,42 +1030,18 @@ const soundboardPlugin: Plugin = {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download-only route (save without auto-play)
|
|
||||||
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
|
app.post('/api/soundboard/download-url', requireAdmin, async (req, res) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const { url } = req.body ?? {};
|
const { url, filename } = req.body ?? {};
|
||||||
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown';
|
||||||
console.log(`${SB} [download-url] ▶ type=${urlType} url=${url}`);
|
console.log(`${SB} [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`);
|
||||||
|
|
||||||
if (!url) { console.log(`${SB} [download-url] ✗ no URL`); res.status(400).json({ error: 'URL erforderlich' }); return; }
|
if (!url) { res.status(400).json({ error: 'URL erforderlich' }); return; }
|
||||||
try { new URL(url); } catch { console.log(`${SB} [download-url] ✗ invalid URL`); res.status(400).json({ error: 'Ungültige URL' }); return; }
|
try { new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; }
|
||||||
if (!isSupportedUrl(url)) { console.log(`${SB} [download-url] ✗ unsupported URL type`); res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
if (!isSupportedUrl(url)) { res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); return; }
|
||||||
|
|
||||||
let savedFile: string;
|
const { savedFile } = await handleUrlDownload(url, filename);
|
||||||
let savedPath: string;
|
|
||||||
|
|
||||||
if (isYtDlpUrl(url)) {
|
|
||||||
console.log(`${SB} [download-url] → delegating to yt-dlp...`);
|
|
||||||
const result = await downloadWithYtDlp(url);
|
|
||||||
savedFile = result.filename;
|
|
||||||
savedPath = result.filepath;
|
|
||||||
} else {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
savedFile = path.basename(parsed.pathname);
|
|
||||||
savedPath = path.join(SOUNDS_DIR, savedFile);
|
|
||||||
console.log(`${SB} [download-url] → direct MP3 download: ${savedFile}`);
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (!r.ok) {
|
|
||||||
console.error(`${SB} [download-url] ✗ HTTP ${r.status} ${r.statusText}`);
|
|
||||||
res.status(400).json({ error: `Download fehlgeschlagen (HTTP ${r.status})` }); return;
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await r.arrayBuffer());
|
|
||||||
fs.writeFileSync(savedPath, buf);
|
|
||||||
console.log(`${SB} [download-url] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (NORMALIZE_ENABLE) { try { await normalizeToCache(savedPath); console.log(`${SB} [download-url] normalized`); } catch (e: any) { console.error(`${SB} [download-url] normalize failed: ${e?.message}`); } }
|
|
||||||
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
console.log(`${SB} [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`);
|
||||||
|
|
|
||||||
|
|
@ -129,20 +129,20 @@ async function apiPlaySound(soundName: string, guildId: string, channelId: strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise<{ saved?: string }> {
|
async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> {
|
||||||
const res = await fetch(`${API_BASE}/play-url`, {
|
const res = await fetch(`${API_BASE}/play-url`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url, guildId, channelId, volume })
|
body: JSON.stringify({ url, guildId, channelId, volume, filename })
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen');
|
if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiDownloadUrl(url: string): Promise<{ saved?: string }> {
|
async function apiDownloadUrl(url: string, filename?: string): Promise<{ saved?: string }> {
|
||||||
const res = await fetch(`${API_BASE}/download-url`, {
|
const res = await fetch(`${API_BASE}/download-url`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url })
|
body: JSON.stringify({ url, filename })
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen');
|
if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen');
|
||||||
|
|
@ -319,6 +319,13 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
const [importUrl, setImportUrl] = useState('');
|
const [importUrl, setImportUrl] = useState('');
|
||||||
const [importBusy, setImportBusy] = useState(false);
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
|
|
||||||
|
// Download modal state
|
||||||
|
const [dlModal, setDlModal] = useState<{
|
||||||
|
url: string; type: 'youtube' | 'instagram' | 'mp3' | null;
|
||||||
|
filename: string; phase: 'input' | 'downloading' | 'done' | 'error';
|
||||||
|
savedName?: string; error?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
/* ── Channels ── */
|
/* ── Channels ── */
|
||||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||||
const [selected, setSelected] = useState('');
|
const [selected, setSelected] = useState('');
|
||||||
|
|
@ -636,32 +643,42 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
} catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUrlImport() {
|
// Open download modal instead of downloading directly
|
||||||
|
function handleUrlImport() {
|
||||||
const trimmed = normalizeUrl(importUrl);
|
const trimmed = normalizeUrl(importUrl);
|
||||||
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
|
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
|
||||||
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
|
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
|
||||||
setImportBusy(true);
|
|
||||||
const urlType = getUrlType(trimmed);
|
const urlType = getUrlType(trimmed);
|
||||||
|
// Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG
|
||||||
|
let defaultName = '';
|
||||||
|
if (urlType === 'mp3') {
|
||||||
|
try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {}
|
||||||
|
}
|
||||||
|
setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual download triggered from modal
|
||||||
|
async function handleModalDownload() {
|
||||||
|
if (!dlModal) return;
|
||||||
|
setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null);
|
||||||
try {
|
try {
|
||||||
let savedName: string | undefined;
|
let savedName: string | undefined;
|
||||||
|
const fn = dlModal.filename.trim() || undefined;
|
||||||
if (selected && guildId && channelId) {
|
if (selected && guildId && channelId) {
|
||||||
// Voice channel selected → download + play
|
const result = await apiPlayUrl(dlModal.url, guildId, channelId, volume, fn);
|
||||||
const result = await apiPlayUrl(trimmed, guildId, channelId, volume);
|
|
||||||
savedName = result.saved;
|
savedName = result.saved;
|
||||||
} else {
|
} else {
|
||||||
// No voice channel → download only
|
const result = await apiDownloadUrl(dlModal.url, fn);
|
||||||
const result = await apiDownloadUrl(trimmed);
|
|
||||||
savedName = result.saved;
|
savedName = result.saved;
|
||||||
}
|
}
|
||||||
|
setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null);
|
||||||
setImportUrl('');
|
setImportUrl('');
|
||||||
const typeLabel = urlType === 'youtube' ? 'YouTube' : urlType === 'instagram' ? 'Instagram' : 'MP3';
|
|
||||||
notify(`${typeLabel} gespeichert${selected ? ' und abgespielt' : ''}: ${savedName ?? ''}`);
|
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
await loadAnalytics();
|
void loadAnalytics();
|
||||||
|
// Auto-close after 2.5s
|
||||||
|
setTimeout(() => setDlModal(null), 2500);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify(e?.message || 'Download fehlgeschlagen', 'error');
|
setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null);
|
||||||
} finally {
|
|
||||||
setImportBusy(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1564,6 +1581,110 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Download Modal ── */}
|
||||||
|
{dlModal && (
|
||||||
|
<div className="dl-modal-overlay" onClick={() => dlModal.phase !== 'downloading' && setDlModal(null)}>
|
||||||
|
<div className="dl-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="dl-modal-header">
|
||||||
|
<span className="material-icons" style={{ fontSize: 20 }}>
|
||||||
|
{dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{dlModal.phase === 'input' ? 'Sound herunterladen'
|
||||||
|
: dlModal.phase === 'downloading' ? 'Wird heruntergeladen...'
|
||||||
|
: dlModal.phase === 'done' ? 'Fertig!'
|
||||||
|
: 'Fehler'}
|
||||||
|
</span>
|
||||||
|
{dlModal.phase !== 'downloading' && (
|
||||||
|
<button className="dl-modal-close" onClick={() => setDlModal(null)}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dl-modal-body">
|
||||||
|
{/* URL badge */}
|
||||||
|
<div className="dl-modal-url">
|
||||||
|
<span className={`dl-modal-tag ${dlModal.type ?? ''}`}>
|
||||||
|
{dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'}
|
||||||
|
</span>
|
||||||
|
<span className="dl-modal-url-text" title={dlModal.url}>
|
||||||
|
{dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filename input (input phase only) */}
|
||||||
|
{dlModal.phase === 'input' && (
|
||||||
|
<div className="dl-modal-field">
|
||||||
|
<label className="dl-modal-label">Dateiname</label>
|
||||||
|
<div className="dl-modal-input-wrap">
|
||||||
|
<input
|
||||||
|
className="dl-modal-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={dlModal.type === 'mp3' ? 'Dateiname...' : 'Wird automatisch erkannt...'}
|
||||||
|
value={dlModal.filename}
|
||||||
|
onChange={e => setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="dl-modal-ext">.mp3</span>
|
||||||
|
</div>
|
||||||
|
<span className="dl-modal-hint">Leer lassen = automatischer Name</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress (downloading phase) */}
|
||||||
|
{dlModal.phase === 'downloading' && (
|
||||||
|
<div className="dl-modal-progress">
|
||||||
|
<div className="dl-modal-spinner" />
|
||||||
|
<span>
|
||||||
|
{dlModal.type === 'youtube' || dlModal.type === 'instagram'
|
||||||
|
? 'Audio wird extrahiert...'
|
||||||
|
: 'MP3 wird heruntergeladen...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{dlModal.phase === 'done' && (
|
||||||
|
<div className="dl-modal-success">
|
||||||
|
<span className="material-icons dl-modal-check">check_circle</span>
|
||||||
|
<span>Gespeichert als <b>{dlModal.savedName}</b></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{dlModal.phase === 'error' && (
|
||||||
|
<div className="dl-modal-error">
|
||||||
|
<span className="material-icons" style={{ color: '#e74c3c' }}>error</span>
|
||||||
|
<span>{dlModal.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{dlModal.phase === 'input' && (
|
||||||
|
<div className="dl-modal-actions">
|
||||||
|
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Abbrechen</button>
|
||||||
|
<button className="dl-modal-submit" onClick={() => void handleModalDownload()}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>download</span>
|
||||||
|
Herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dlModal.phase === 'error' && (
|
||||||
|
<div className="dl-modal-actions">
|
||||||
|
<button className="dl-modal-cancel" onClick={() => setDlModal(null)}>Schliessen</button>
|
||||||
|
<button className="dl-modal-submit" onClick={() => setDlModal(prev => prev ? { ...prev, phase: 'input', error: undefined } : null)}>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>refresh</span>
|
||||||
|
Nochmal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2032,6 +2032,141 @@
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
Download Modal
|
||||||
|
──────────────────────────────────────────── */
|
||||||
|
.dl-modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, .55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
animation: fade-in 150ms ease;
|
||||||
|
}
|
||||||
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.dl-modal {
|
||||||
|
width: 420px; max-width: 92vw;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 60px rgba(0, 0, 0, .5);
|
||||||
|
animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1);
|
||||||
|
}
|
||||||
|
@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } }
|
||||||
|
|
||||||
|
.dl-modal-header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .06);
|
||||||
|
font-size: 14px; font-weight: 700;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
.dl-modal-header .material-icons { color: var(--accent); }
|
||||||
|
|
||||||
|
.dl-modal-close {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
border: none; background: rgba(255,255,255,.06);
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); }
|
||||||
|
|
||||||
|
.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
/* URL display */
|
||||||
|
.dl-modal-url {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 10px; border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, .2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dl-modal-tag {
|
||||||
|
flex-shrink: 0; padding: 2px 8px; border-radius: 6px;
|
||||||
|
font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; }
|
||||||
|
.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; }
|
||||||
|
.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
||||||
|
.dl-modal-url-text {
|
||||||
|
font-size: 11px; color: var(--text-faint);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filename field */
|
||||||
|
.dl-modal-field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.dl-modal-input-wrap {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, .15);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.dl-modal-input-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
.dl-modal-input {
|
||||||
|
flex: 1; border: none; background: transparent;
|
||||||
|
padding: 8px 10px; color: var(--text-normal);
|
||||||
|
font-size: 13px; font-family: var(--font); outline: none;
|
||||||
|
}
|
||||||
|
.dl-modal-input::placeholder { color: var(--text-faint); }
|
||||||
|
.dl-modal-ext {
|
||||||
|
padding: 0 10px; font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--text-faint); background: rgba(255, 255, 255, .04);
|
||||||
|
align-self: stretch; display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.dl-modal-hint { font-size: 10px; color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* Progress spinner */
|
||||||
|
.dl-modal-progress {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 20px 0; justify-content: center;
|
||||||
|
font-size: 13px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.dl-modal-spinner {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
border: 3px solid rgba(var(--accent-rgb), .2);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 800ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success */
|
||||||
|
.dl-modal-success {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 16px 0; justify-content: center;
|
||||||
|
font-size: 13px; color: var(--text-normal);
|
||||||
|
}
|
||||||
|
.dl-modal-check { color: #2ecc71; font-size: 28px; }
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.dl-modal-error {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 0; justify-content: center;
|
||||||
|
font-size: 13px; color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.dl-modal-actions {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
}
|
||||||
|
.dl-modal-cancel {
|
||||||
|
padding: 7px 14px; border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1); background: transparent;
|
||||||
|
color: var(--text-muted); font-size: 12px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); }
|
||||||
|
.dl-modal-submit {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: none; background: var(--accent);
|
||||||
|
color: #fff; font-size: 12px; font-weight: 700;
|
||||||
|
cursor: pointer; transition: filter var(--transition);
|
||||||
|
}
|
||||||
|
.dl-modal-submit:hover { filter: brightness(1.15); }
|
||||||
|
|
||||||
/* ────────────────────────────────────────────
|
/* ────────────────────────────────────────────
|
||||||
Utility
|
Utility
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue