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:
Daniel 2026-03-06 23:59:31 +01:00
parent 0b2ba0ef86
commit 9ff8a38547
4 changed files with 332 additions and 76 deletions

View file

@ -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`, {
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(() => ({}));
if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen');
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`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
body: JSON.stringify({ url, filename })
});
const data = await res.json().catch(() => ({}));
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 [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 ── */
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
const [selected, setSelected] = useState('');
@ -636,32 +643,42 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
} 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);
if (!trimmed) return notify('Bitte einen Link eingeben', 'error');
if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error');
setImportBusy(true);
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 {
let savedName: string | undefined;
const fn = dlModal.filename.trim() || undefined;
if (selected && guildId && channelId) {
// Voice channel selected → download + play
const result = await apiPlayUrl(trimmed, guildId, channelId, volume);
const result = await apiPlayUrl(dlModal.url, guildId, channelId, volume, fn);
savedName = result.saved;
} else {
// No voice channel → download only
const result = await apiDownloadUrl(trimmed);
const result = await apiDownloadUrl(dlModal.url, fn);
savedName = result.saved;
}
setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null);
setImportUrl('');
const typeLabel = urlType === 'youtube' ? 'YouTube' : urlType === 'instagram' ? 'Instagram' : 'MP3';
notify(`${typeLabel} gespeichert${selected ? ' und abgespielt' : ''}: ${savedName ?? ''}`);
setRefreshKey(k => k + 1);
await loadAnalytics();
void loadAnalytics();
// Auto-close after 2.5s
setTimeout(() => setDlModal(null), 2500);
} catch (e: any) {
notify(e?.message || 'Download fehlgeschlagen', 'error');
} finally {
setImportBusy(false);
setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null);
}
}
@ -1564,6 +1581,110 @@ export default function SoundboardTab({ data }: SoundboardTabProps) {
</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>
);
}