feat: one-pager design

This commit is contained in:
2025-12-23 19:54:56 +01:00
parent a2c4f29172
commit eff8c3ee2f
3 changed files with 569 additions and 919 deletions
+257 -465
View File
@@ -1,520 +1,312 @@
document.addEventListener('DOMContentLoaded', () => {
// --- Selektoren & Globale Variablen ---
const selectors = {
form: '#upload-form',
submitButton: '#submit-button',
platformRadios: 'input[name="platform"]',
ytOptionsDiv: '#youtube-options',
ytQualityDiv: '#youtube-quality',
ytFormatRadios: 'input[name="yt_format"]',
mp3QualitySection: '#mp3-quality-section',
mp4QualitySection: '#mp4-quality-section',
codecOptionsSection: '#codec-options-section',
progressBar: '#progress-bar',
statusMessage: '#status-message',
logContent: '#log-content',
logOutput: '#log-output',
resultUrlArea: '#result-url-area',
resultUrlLink: '#result-url',
copyResultUrlLink: '#copy-result-url',
errorMessage: '#error-message',
historyTableBody: '#history-table tbody',
clearHistoryButton: '#clear-history-button',
contextMenu: '#context-menu',
queueInfo: '#queue-info',
statsTotalJobs: '#stats-total-jobs',
statsAvgDuration: '#stats-avg-duration',
statsTotalSize: '#stats-total-size',
urlHelpText: '#urlHelp',
// Selektoren für das Overlay
processingOverlay: '#processing-overlay',
overlayMessage: '#overlay-message',
// NEU: Selektoren für Status im Overlay
overlayStatusText: '#overlay-status-text',
overlayProgressBar: '#overlay-progress-bar',
// --- Selektoren ---
const dom = {
form: document.getElementById('upload-form'),
urlInput: document.getElementById('url'),
platformInput: document.getElementById('input-platform'),
submitBtn: document.getElementById('submit-button'),
// Detection Feedback
badgeContainer: document.getElementById('platform-badge-container'),
detectedIcon: document.getElementById('detected-icon'),
detectedText: document.getElementById('detected-text'),
urlIcon: document.getElementById('url-icon'),
// Options
optionsContainer: document.getElementById('options-container'),
ytOptions: document.getElementById('youtube-options'),
codecSection: document.getElementById('codec-options-section'),
// YouTube Specifics
ytFormatRadios: document.querySelectorAll('input[name="yt_format"]'),
qualityWrapper: document.getElementById('quality-wrapper'),
mp3Select: document.getElementById('mp3_bitrate'),
mp4Select: document.getElementById('mp4_quality'),
// Codec Switch
codecSwitch: document.getElementById('codec-switch'),
codecPreference: document.getElementById('codec_preference'),
// Progress & Result
progressContainer: document.getElementById('progress-container'),
progressBar: document.getElementById('progress-bar'),
statusMessage: document.getElementById('status-message'),
logContent: document.getElementById('log-content'),
resultContainer: document.getElementById('result-container'),
resultUrl: document.getElementById('result-url'),
errorMessage: document.getElementById('error-message'),
// History
historyTableBody: document.querySelector('#history-table tbody'),
clearHistoryBtn: document.getElementById('clear-history-button'),
contextMenu: document.getElementById('context-menu')
};
const dom = {};
let pollingInterval = null;
let currentJobId = null;
let isPolling = false;
const historyEnabled = !!document.querySelector(selectors.clearHistoryButton);
const videoPlatforms = ['YouTube', 'TikTok', 'Instagram', 'Twitter'];
let pollingInterval = null;
const memeMessages = [
"Hacking the mainframe...",
"Route Gibson durch die Firewall...",
"Einen Moment, ich Binge gerade das Internet durch...",
"Lade 1.21 Gigawatt herunter...",
"Komprimiere die Daten... mit purer Willenskraft.",
"Die Bits und Bytes tanzen Cha-Cha-Cha.",
"Frage die NSA nach dem schnellsten Weg...",
"Polishing the pixels...",
"Die Leitung glüht, alles nach Plan!",
"Füttere den Hamster im Serverraum...",
"Kalibriere den Fluxkompensator...",
"Optimiere den Warp-Antrieb...",
// --- Plattform Definitionen (Regex & Farben) ---
const platforms = [
{ name: 'SoundCloud', pattern: /soundcloud\.com/, icon: 'fa-soundcloud', color: '#ff5500' },
{ name: 'YouTube', pattern: /(youtube\.com|youtu\.be)/, icon: 'fa-youtube', color: '#ff0000' },
{ name: 'TikTok', pattern: /tiktok\.com/, icon: 'fa-tiktok', color: '#000000' },
{ name: 'Instagram', pattern: /instagram\.com/, icon: 'fa-instagram', color: '#E1306C' },
{ name: 'Twitter', pattern: /(twitter\.com|x\.com)/, icon: 'fa-x-twitter', color: '#000000' }
];
// --- Initialisierung ---
// --- Init ---
function init() {
for (const key in selectors) {
dom[key] = document.querySelector(selectors[key]);
if (!dom[key] && !['copyResultUrlLink', 'clearHistoryButton', 'historyTableBody', 'contextMenu'].includes(key)) {
console.warn(`DOM-Element nicht gefunden: ${selectors[key]}`);
}
}
dom.platformRadios = document.querySelectorAll(selectors.platformRadios);
dom.ytFormatRadios = document.querySelectorAll(selectors.ytFormatRadios);
setupEventListeners();
updateDynamicOptionsVisibility();
if (historyEnabled) {
fetchHistory();
} else {
if(dom.historyTableBody) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">- Verlauf deaktiviert -</td></tr>';
}
}
resetUIState();
fetchStats();
console.log("Uploader UI initialisiert.");
loadHistory();
}
// --- Event Listener Setup ---
function setupEventListeners() {
if (dom.form) dom.form.addEventListener('submit', handleFormSubmit);
dom.platformRadios.forEach(radio => radio.addEventListener('change', updateDynamicOptionsVisibility));
dom.ytFormatRadios.forEach(radio => radio.addEventListener('change', updateYoutubeQualityVisibility));
if (dom.clearHistoryButton) dom.clearHistoryButton.addEventListener('click', handleClearHistory);
document.addEventListener('click', hideContextMenu);
if (dom.contextMenu) dom.contextMenu.addEventListener('click', handleContextMenuClick);
if (dom.historyTableBody && historyEnabled) {
dom.historyTableBody.addEventListener('contextmenu', (event) => {
const targetLink = event.target.closest('a[data-url]');
if (targetLink) showContextMenu(event, targetLink.dataset.url);
// Auto-Detect bei Eingabe
dom.urlInput.addEventListener('input', handleUrlInput);
// Form Submit
dom.form.addEventListener('submit', handleFormSubmit);
// YouTube Format Toggle (MP3/MP4)
dom.ytFormatRadios.forEach(radio => {
radio.addEventListener('change', updateYtQualityVisibility);
});
// Codec Switch Toggle
if(dom.codecSwitch) {
dom.codecSwitch.addEventListener('change', (e) => {
dom.codecPreference.value = e.target.checked ? 'h264' : 'original';
});
}
}
// --- UI Update Funktionen ---
function resetUIState() {
if (dom.submitButton) {
dom.submitButton.disabled = false;
dom.submitButton.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Download starten';
}
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
if (dom.resultUrlArea) dom.resultUrlArea.classList.add('d-none');
if (dom.logContent) dom.logContent.textContent = '';
if (dom.queueInfo) dom.queueInfo.classList.add('d-none');
updateAllStatusMessages('Bereit.');
updateAllProgressBars(0, false, false, false);
currentJobId = null;
isPolling = false;
hideProcessingOverlay();
stopPolling();
console.log("UI State reset.");
}
function setUIProcessing(isStarting = false) {
if (dom.submitButton) dom.submitButton.disabled = true;
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
updateAllStatusMessages('Sende Auftrag...');
if (isStarting) {
if (dom.resultUrlArea) dom.resultUrlArea.classList.add('d-none');
if (dom.logContent) dom.logContent.textContent = '';
updateAllProgressBars(0, false, false, false);
// History
if(dom.clearHistoryBtn) dom.clearHistoryBtn.addEventListener('click', clearHistory);
document.addEventListener('click', hideContextMenu);
if(dom.historyTableBody) {
dom.historyTableBody.addEventListener('contextmenu', handleHistoryRightClick);
dom.contextMenu.addEventListener('click', handleContextMenuClick);
}
}
// NEU: Funktion, die BEIDE Fortschrittsbalken aktualisiert
function updateAllProgressBars(value, isError = false, isRunning = false, isQueued = false) {
const percentage = Math.max(0, Math.min(100, Math.round(value)));
// Funktion zur Aktualisierung eines einzelnen Balkens
const updateSingleBar = (barElement) => {
if (!barElement) return;
barElement.style.width = `${percentage}%`;
barElement.textContent = `${percentage}%`;
// --- Logik: URL Erkennung ---
function handleUrlInput() {
const url = dom.urlInput.value.trim();
const detected = platforms.find(p => p.pattern.test(url));
if (detected) {
// UI Feedback
dom.platformInput.value = detected.name;
dom.detectedText.textContent = `${detected.name} erkannt`;
dom.detectedIcon.className = `fab ${detected.icon}`;
dom.detectedIcon.style.color = detected.color;
dom.badgeContainer.style.opacity = '1';
// Spezifische Klassen für den Haupt-Balken
if (barElement.id === 'progress-bar') {
barElement.setAttribute('aria-valuenow', percentage);
barElement.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-secondary', 'progress-bar-animated', 'progress-bar-striped');
if (isError) barElement.classList.add('bg-danger');
else if (isQueued) barElement.classList.add('bg-secondary');
else if (percentage === 100 && !isRunning) barElement.classList.add('bg-success');
else if (isRunning) barElement.classList.add('bg-info', 'progress-bar-striped', 'progress-bar-animated');
else barElement.classList.add('bg-info');
}
// Icon im Input Feld färben
dom.urlIcon.className = `fab ${detected.icon}`;
dom.urlIcon.style.color = detected.color;
// Spezifische Klassen für den Overlay-Balken (hat eigene CSS-Stile)
if (barElement.id === 'overlay-progress-bar') {
barElement.classList.remove('progress-bar-striped', 'progress-bar-animated');
if (isRunning) {
barElement.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
};
updateSingleBar(dom.progressBar);
updateSingleBar(dom.overlayProgressBar);
}
// NEU: Funktion, die BEIDE Status-Nachrichten aktualisiert
function updateAllStatusMessages(message, position = null, totalQueued = null) {
let displayMessage = message || '...';
if (position !== null && totalQueued !== null) {
displayMessage = `${message} (Position ${position} von ${totalQueued})`;
}
if (dom.statusMessage) dom.statusMessage.textContent = displayMessage;
if (dom.overlayStatusText) dom.overlayStatusText.textContent = displayMessage;
}
function appendLog(message, type = 'log') {
if (!dom.logContent || !message) return;
const timestamp = new Date().toLocaleTimeString();
const logLine = `${timestamp} - ${message.trim()}\n`;
dom.logContent.textContent += logLine;
if (dom.logOutput) dom.logOutput.scrollTop = dom.logOutput.scrollHeight;
if (type === 'error') console.error(message);
else if (type === 'warn') console.warn(message);
}
function showError(message) {
if (!dom.errorMessage) return;
let displayMessage = message || "Unbekannter Fehler.";
// ... (Fehlertext-Logik bleibt unverändert)
const lowerCaseMessage = message ? message.toLowerCase() : "";
if (lowerCaseMessage.includes("login is required") || lowerCaseMessage.includes("age-restricted") || lowerCaseMessage.includes("instagramloginrequired") || lowerCaseMessage.includes("twitterloginrequired")) {
displayMessage = "Dieser Inhalt erfordert eine Anmeldung oder ist altersbeschränkt und kann nicht direkt heruntergeladen werden.";
} else if (lowerCaseMessage.includes("video unavailable")) {
displayMessage = "Fehler: Dieses Video ist nicht (mehr) verfügbar.";
} else if (lowerCaseMessage.includes("unsupported url")) {
displayMessage = "Fehler: Die eingegebene URL wird nicht unterstützt oder ist kein gültiger Link für die gewählte Plattform.";
} else if (lowerCaseMessage.includes("403") || lowerCaseMessage.includes("access denied")) {
displayMessage = "Fehler: Zugriff auf den Inhalt verweigert (403).";
} else if (lowerCaseMessage.includes("404") || lowerCaseMessage.includes("not found")) {
displayMessage = "Fehler: Inhalt nicht gefunden (404).";
} else if (lowerCaseMessage.includes("already processed")) {
displayMessage = message;
} else if (lowerCaseMessage.includes("worker-fehler") || lowerCaseMessage.includes("schwerer worker-fehler")) {
displayMessage = `Interner Serverfehler: ${message}`;
} else if (lowerCaseMessage.includes("job nicht gefunden")) {
displayMessage = message;
} else if (lowerCaseMessage.includes("ungültige url für instagram")) {
displayMessage = "Fehler: Ungültige URL für Instagram. Es werden nur Reel-Links unterstützt (z.B. .../reel/...).";
} else if (lowerCaseMessage.includes("ungültige url für twitter")) {
displayMessage = "Fehler: Ungültige URL für Twitter/X. Es werden nur Tweet-Links unterstützt (z.B. .../status/...).";
}
dom.errorMessage.textContent = displayMessage;
dom.errorMessage.classList.remove('d-none');
updateAllStatusMessages('Fehler!');
const currentProgress = dom.progressBar ? parseInt(dom.progressBar.getAttribute('aria-valuenow')) : 0;
updateAllProgressBars(currentProgress, true, false, false);
appendLog(`Fehler angezeigt: ${displayMessage}`, 'error');
}
function showResult(url) {
if (!dom.resultUrlArea || !dom.resultUrlLink || !url) return;
dom.resultUrlLink.href = url;
dom.resultUrlLink.textContent = url;
dom.resultUrlArea.classList.remove('d-none');
if (dom.errorMessage) dom.errorMessage.classList.add('d-none');
}
// --- Overlay Funktionen ---
function showProcessingOverlay() {
if (!dom.processingOverlay || !dom.overlayMessage) return;
const randomIndex = Math.floor(Math.random() * memeMessages.length);
dom.overlayMessage.textContent = memeMessages[randomIndex];
dom.processingOverlay.classList.add('visible');
}
function hideProcessingOverlay() {
if (!dom.processingOverlay) return;
dom.processingOverlay.classList.remove('visible');
}
// --- Dynamische Optionen (unverändert) ---
function updateDynamicOptionsVisibility() {
const selectedPlatform = document.querySelector('input[name="platform"]:checked')?.value;
if (!dom.ytOptionsDiv || !dom.codecOptionsSection || !dom.urlHelpText) return;
dom.ytOptionsDiv.classList.add('d-none');
dom.codecOptionsSection.classList.add('d-none');
if(dom.mp3QualitySection) dom.mp3QualitySection.classList.add('d-none');
if(dom.mp4QualitySection) dom.mp4QualitySection.classList.add('d-none');
if (selectedPlatform === 'Instagram' || selectedPlatform === 'Twitter') {
dom.urlHelpText.classList.remove('d-none');
// Optionen anzeigen
showOptionsForPlatform(detected.name);
} else {
dom.urlHelpText.classList.add('d-none');
}
if (selectedPlatform === 'YouTube') {
dom.ytOptionsDiv.classList.remove('d-none');
updateYoutubeQualityVisibility();
}
else if (videoPlatforms.includes(selectedPlatform) && selectedPlatform !== 'YouTube') {
dom.codecOptionsSection.classList.remove('d-none');
}
}
function updateYoutubeQualityVisibility() {
const selectedFormat = document.querySelector('input[name="yt_format"]:checked')?.value;
if (!dom.mp3QualitySection || !dom.mp4QualitySection || !dom.ytQualityDiv || !dom.codecOptionsSection) return;
dom.ytQualityDiv.classList.remove('d-none');
dom.mp3QualitySection.classList.add('d-none');
dom.mp4QualitySection.classList.add('d-none');
dom.codecOptionsSection.classList.add('d-none');
if (selectedFormat === 'mp3') {
dom.mp3QualitySection.classList.remove('d-none');
} else if (selectedFormat === 'mp4') {
dom.mp4QualitySection.classList.remove('d-none');
dom.codecOptionsSection.classList.remove('d-none');
// Reset wenn unbekannt oder leer
dom.badgeContainer.style.opacity = '0';
dom.urlIcon.className = 'fas fa-link text-muted';
dom.urlIcon.style.color = '';
hideAllOptions();
}
}
// --- Event Handler ---
async function handleFormSubmit(event) {
event.preventDefault();
if (isPolling) {
alert("Bitte warte, bis der aktuelle Auftrag abgeschlossen ist, bevor du einen neuen startest.");
return;
function showOptionsForPlatform(platformName) {
dom.optionsContainer.classList.add('show');
// Reset specific sections
dom.ytOptions.classList.add('d-none');
dom.codecSection.classList.add('d-none');
if (platformName === 'YouTube') {
dom.ytOptions.classList.remove('d-none');
} else if (['TikTok', 'Instagram', 'Twitter'].includes(platformName)) {
dom.codecSection.classList.remove('d-none');
} else {
// SoundCloud braucht keine extra Optionen in diesem Design (Default ist MP3 Best)
dom.optionsContainer.classList.remove('show');
}
resetUIState();
setUIProcessing(true);
showProcessingOverlay();
}
function hideAllOptions() {
dom.optionsContainer.classList.remove('show');
setTimeout(() => {
dom.ytOptions.classList.add('d-none');
dom.codecSection.classList.add('d-none');
}, 500); // Warten bis Animation fertig
}
function updateYtQualityVisibility() {
const format = document.querySelector('input[name="yt_format"]:checked').value;
if (format === 'mp3') {
dom.mp3Select.classList.remove('d-none');
dom.mp4Select.classList.add('d-none');
dom.codecSection.classList.add('d-none');
} else {
dom.mp3Select.classList.add('d-none');
dom.mp4Select.classList.remove('d-none');
dom.codecSection.classList.remove('d-none');
}
}
// --- Logik: Submit & Polling ---
async function handleFormSubmit(e) {
e.preventDefault();
// Validierung
if(dom.badgeContainer.style.opacity === '0' && dom.urlInput.value.length > 0) {
// Fallback für unbekannte URLs -> Standard SoundCloud probieren oder Warnen
dom.platformInput.value = "SoundCloud";
}
// UI Transition
dom.form.classList.add('d-none');
dom.progressContainer.classList.remove('d-none');
dom.resultContainer.classList.add('d-none');
dom.errorMessage.classList.add('d-none');
dom.logContent.textContent = "Verbindung wird hergestellt...";
dom.progressBar.style.width = "5%";
const formData = new FormData(dom.form);
try {
const response = await fetch('/start_download', { method: 'POST', body: formData });
const result = await response.json();
if (response.ok && result.job_id) {
currentJobId = result.job_id;
updateAllStatusMessages(result.message || 'Auftrag gesendet...');
appendLog(`Auftrag ${currentJobId} gestartet: ${result.message || ''}`, 'info');
startPolling();
} else {
throw new Error(result.error || `Serverfehler: ${response.status}`);
}
} catch (error) {
console.error('Fehler beim Starten des Downloads:', error);
showError(`Fehler beim Start: ${error.message}`);
resetUIState();
}
}
async function handleClearHistory() {
if (!dom.clearHistoryButton || !dom.historyTableBody) return;
if (confirm('Möchtest du wirklich den gesamten Verlauf löschen?')) {
dom.clearHistoryButton.disabled = true;
try {
const response = await fetch('/clear_history', { method: 'POST' });
if (response.ok) {
appendLog('Verlauf erfolgreich gelöscht.', 'info');
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Verlauf wurde gelöscht.</td></tr>';
} else {
const result = await response.json(); throw new Error(result.error || 'Unbekannter Fehler.');
}
} catch (error) {
console.error('Fehler beim Löschen des Verlaufs:', error); showError(`Fehler beim Löschen: ${error.message}`);
} finally {
dom.clearHistoryButton.disabled = false;
showError(result.error || "Serverfehler beim Starten.");
}
} catch (err) {
showError(`Verbindungsfehler: ${err.message}`);
}
}
// --- Polling ---
function startPolling() {
if (!currentJobId) return;
stopPolling();
isPolling = true;
if (dom.submitButton) {
dom.submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verarbeite...';
dom.submitButton.disabled = true;
}
pollingInterval = setInterval(fetchStatus, 2000);
fetchStatus();
console.log(`Polling gestartet für Job ${currentJobId}.`);
pollingInterval = setInterval(async () => {
try {
const res = await fetch(`/status?job_id=${currentJobId}`);
if (res.status === 404) { stopPolling(); showError("Job verloren gegangen."); return; }
const status = await res.json();
updateProgressUI(status);
if (status.status === 'completed') {
stopPolling();
showResult(status.result_url);
} else if (status.status === 'error' || status.error) {
stopPolling();
showError(status.error || status.message);
}
} catch (err) {
console.error(err);
}
}, 1500);
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
isPolling = false;
console.log("Polling gestoppt.");
clearInterval(pollingInterval);
}
function updateProgressUI(status) {
const pct = status.progress || 0;
dom.progressBar.style.width = `${pct}%`;
if(status.message) dom.statusMessage.textContent = status.message;
if(status.logs && status.logs.length > 0) {
dom.logContent.textContent = status.logs[status.logs.length - 1];
}
}
async function fetchStatus() {
if (!currentJobId || !isPolling) {
stopPolling();
function showResult(url) {
dom.progressContainer.classList.add('d-none');
dom.resultContainer.classList.remove('d-none');
dom.resultUrl.href = url;
loadHistory(); // Verlauf aktualisieren
}
function showError(msg) {
dom.progressContainer.classList.add('d-none');
dom.form.classList.remove('d-none'); // Form wieder zeigen
dom.errorMessage.textContent = msg;
dom.errorMessage.classList.remove('d-none');
}
// --- History Logic (Minimal) ---
async function loadHistory() {
if(!dom.historyTableBody) return;
try {
const res = await fetch('/history');
const data = await res.json();
renderHistory(data);
} catch(e) { console.error("History Error", e); }
}
function renderHistory(data) {
dom.historyTableBody.innerHTML = '';
if(!data || !data.length) {
dom.historyTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Kein Verlauf vorhanden.</td></tr>';
return;
}
try {
const response = await fetch(`/status?job_id=${currentJobId}`);
if (response.status === 404) {
const status = await response.json();
showError(status.error || "Auftrag nicht gefunden (möglicherweise zu alt).");
stopPolling();
hideProcessingOverlay();
resetSubmitButton();
return;
}
if (!response.ok) throw new Error(`Status-Serverfehler: ${response.status}`);
const status = await response.json();
if (dom.logContent && Array.isArray(status.logs)) {
dom.logContent.textContent = status.logs.join('\n') + '\n';
if (dom.logOutput) dom.logOutput.scrollTop = dom.logOutput.scrollHeight;
}
const isRunning = status.running === true;
const isQueued = status.status === 'queued';
const isCompleted = status.status === 'completed';
const isError = !!status.error || status.status === 'error';
const isNotFound = status.status === 'not_found';
// Status und Fortschritt an beide UI-Teile senden
updateAllProgressBars(status.progress || 0, isError, isRunning, isQueued);
if (isQueued && status.position !== undefined && status.total_queued !== undefined) {
updateAllStatusMessages(status.message || 'In Warteschlange...', status.position, status.total_queued);
} else {
updateAllStatusMessages(status.message || '...');
}
if (isCompleted || isError || isNotFound) {
stopPolling();
hideProcessingOverlay();
if (isError) {
showError(status.error || status.message);
} else if (isCompleted) {
updateAllStatusMessages('Abgeschlossen!');
updateAllProgressBars(100, false, false, false);
if (status.result_url) showResult(status.result_url);
if (historyEnabled) fetchHistory();
fetchStats();
} else {
showError(status.error || status.message || "Auftrag beendet, aber Status unklar.");
}
resetSubmitButton();
} else if (isRunning || isQueued) {
if (dom.submitButton && !dom.submitButton.disabled) {
dom.submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Verarbeite...';
dom.submitButton.disabled = true;
}
}
} catch (error) {
console.error('Polling-Fehler:', error);
appendLog(`Polling fehlgeschlagen: ${error.message}`, 'error');
showError(`Polling-Fehler: ${error.message}.`);
stopPolling();
resetUIState();
}
}
function resetSubmitButton() {
if (dom.submitButton) {
dom.submitButton.disabled = false;
dom.submitButton.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> Download starten';
}
}
// --- History (unverändert) ---
async function fetchHistory() {
if (!dom.historyTableBody || !historyEnabled) return;
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted"><i class="fas fa-spinner fa-spin"></i> Lade Verlauf...</td></tr>';
try {
const response = await fetch('/history');
if (!response.ok) throw new Error(`Serverfehler History: ${response.status}`);
const history = await response.json();
renderHistory(history);
} catch (error) {
console.error('Fehler Laden Verlauf:', error);
dom.historyTableBody.innerHTML = `<tr><td colspan="5" class="text-center text-danger">Fehler Laden Verlauf: ${error.message}</td></tr>`;
}
}
function renderHistory(historyData) {
if (!dom.historyTableBody || !historyEnabled) return;
dom.historyTableBody.innerHTML = '';
if (!historyData || !Array.isArray(historyData) || historyData.length === 0) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Keine Einträge im Verlauf.</td></tr>'; return;
}
historyData.forEach(entry => {
const row = dom.historyTableBody.insertRow();
row.insertCell().textContent = entry.timestamp || '';
const platformCell = row.insertCell(); platformCell.classList.add('text-center');
let platformIcon = '<i class="fas fa-question-circle text-muted" title="Unbekannt"></i>';
const platform = entry.platform || 'Unbekannt';
if (platform === 'SoundCloud') platformIcon = '<i class="fab fa-soundcloud text-warning" title="SoundCloud"></i>';
else if (platform === 'YouTube') platformIcon = '<i class="fab fa-youtube text-danger" title="YouTube"></i>';
else if (platform === 'TikTok') platformIcon = '<i class="fab fa-tiktok" title="TikTok"></i>';
else if (platform === 'Instagram') platformIcon = '<i class="fab fa-instagram" title="Instagram Reel"></i>';
else if (platform === 'Twitter') platformIcon = '<i class="fab fa-x-twitter" title="Twitter/X"></i>';
platformCell.innerHTML = platformIcon;
const titleCell = row.insertCell(); titleCell.textContent = entry.title || 'N/A'; titleCell.title = entry.title || '';
createLinkCell(row.insertCell(), entry['source_url']);
createLinkCell(row.insertCell(), entry['s3_url']);
data.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="small text-muted">${item.timestamp.split(' ')[1]}</td>
<td class="text-center"><i class="fab fa-${getIconForPlatform(item.platform)}"></i></td>
<td class="text-truncate" style="max-width: 150px;" title="${item.title}">${item.title}</td>
<td class="text-end"><a href="${item.s3_url}" target="_blank" class="btn btn-sm btn-light border" data-url="${item.s3_url}"><i class="fas fa-download"></i></a></td>
`;
dom.historyTableBody.appendChild(row);
});
}
function createLinkCell(cell, url) {
if (!cell || !url) { cell.textContent = url || 'N/A'; return; }
const link = document.createElement('a'); link.href = url;
link.textContent = url.length > 50 ? url.substring(0, 47) + '...' : url;
link.title = url; link.target = "_blank"; link.rel = "noopener noreferrer";
link.dataset.url = url;
cell.appendChild(link);
function getIconForPlatform(p) {
if(p === 'SoundCloud') return 'soundcloud text-warning';
if(p === 'YouTube') return 'youtube text-danger';
if(p === 'TikTok') return 'tiktok';
if(p === 'Instagram') return 'instagram text-danger';
if(p === 'Twitter') return 'x-twitter';
return 'question';
}
// --- Kontextmenü (unverändert) ---
let currentContextMenuUrl = null;
function showContextMenu(event, url) {
event.preventDefault();
if (!dom.contextMenu || !url) return;
currentContextMenuUrl = url;
dom.contextMenu.style.top = `${event.clientY}px`;
dom.contextMenu.style.left = `${event.clientX}px`;
dom.contextMenu.classList.remove('d-none');
async function clearHistory() {
if(confirm("Verlauf wirklich löschen?")) {
await fetch('/clear_history', {method: 'POST'});
loadHistory();
}
}
// Context Menu Logic
let contextUrl = null;
function handleHistoryRightClick(e) {
const linkBtn = e.target.closest('a[data-url]');
if(linkBtn) {
e.preventDefault();
contextUrl = linkBtn.dataset.url;
dom.contextMenu.style.top = `${e.pageY}px`;
dom.contextMenu.style.left = `${e.pageX}px`;
dom.contextMenu.classList.remove('d-none');
}
}
function handleContextMenuClick(e) {
if(e.target.closest('[data-action="copy"]') && contextUrl) {
navigator.clipboard.writeText(contextUrl);
dom.contextMenu.classList.add('d-none');
}
}
function hideContextMenu() {
if (dom.contextMenu) dom.contextMenu.classList.add('d-none');
currentContextMenuUrl = null;
}
function handleContextMenuClick(event) {
const action = event.target.closest('[data-action]')?.dataset.action;
if (action && currentContextMenuUrl) {
if (action === 'open') window.open(currentContextMenuUrl, '_blank');
else if (action === 'copy') { copyToClipboard(currentContextMenuUrl); appendLog("URL aus History kopiert.", "info"); }
}
hideContextMenu();
if(dom.contextMenu) dom.contextMenu.classList.add('d-none');
}
// --- Statistik (unverändert) ---
async function fetchStats() {
if (!dom.statsTotalJobs || !dom.statsAvgDuration || !dom.statsTotalSize) return;
try {
const response = await fetch('/stats');
if (!response.ok) throw new Error(`Statistik-Serverfehler: ${response.status}`);
const stats = await response.json();
dom.statsTotalJobs.textContent = stats.total_jobs ?? 'N/A';
dom.statsAvgDuration.textContent = stats.average_duration_seconds ?? 'N/A';
dom.statsTotalSize.textContent = stats.total_size_formatted ?? 'N/A';
} catch (error) {
console.error('Fehler Laden Statistiken:', error);
}
}
// --- Hilfsfunktionen (unverändert) ---
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => appendLog("Link kopiert.", "info"))
.catch(err => { console.error('Async Copy failed:', err); alert("Kopieren fehlgeschlagen."); });
}
// --- Start ---
init();
}); // Ende DOMContentLoaded
});
+143 -205
View File
@@ -1,242 +1,180 @@
/* Custom Styles (Ergänzung zu Bootstrap) */
html {
height: 100%; /* Wichtig für min-height im body */
/* Modern Reset & Base */
:root {
--primary-color: #6366f1; /* Indigo */
--secondary-color: #a855f7; /* Purple */
--bg-dark: #0f172a;
--text-main: #1e293b;
}
body {
background-color: #e9ecef; /* Hellerer Hintergrund */
display: flex; /* Flexbox aktivieren */
flex-direction: column; /* Hauptachse vertikal */
min-height: 100vh; /* Mindesthöhe des Viewports */
font-family: 'Inter', sans-serif;
background-color: #f1f5f9;
color: var(--text-main);
overflow-x: hidden;
}
/* Hauptinhalts-Container soll wachsen */
.main-content {
flex: 1 0 auto; /* flex-grow: 1, flex-shrink: 0, flex-basis: auto */
/* Kein margin-bottom hier, der Footer kümmert sich um den Abstand */
/* Animated Background Shapes */
.bg-shape {
position: fixed;
border-radius: 50%;
filter: blur(80px);
z-index: -1;
opacity: 0.6;
animation: float 10s infinite ease-in-out;
}
/* Footer soll nicht schrumpfen und hat eigenen Abstand/Styling */
.page-footer {
flex-shrink: 0; /* Verhindert, dass der Footer schrumpft */
background-color: #f8f9fa; /* Optional: Leichter Hintergrund für Footer */
/* mt-auto im HTML sorgt für den Push nach unten */
/* padding-top und padding-bottom im HTML oder hier definieren */
.shape-1 {
top: -10%;
left: -10%;
width: 600px;
height: 600px;
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
}
.page-footer hr {
margin-top: 0; /* Abstand der Linie anpassen */
margin-bottom: 1rem;
.shape-2 {
bottom: -10%;
right: -10%;
width: 500px;
height: 500px;
background: linear-gradient(to left, #3b82f6, #06b6d4);
animation-delay: -5s;
}
.page-footer p {
margin-bottom: 0.5rem;
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30px, 20px); }
}
.page-footer i {
margin-right: 5px;
/* Hero Card */
.hero-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 24px;
padding: 3rem;
width: 100%;
max-width: 650px;
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
}
.page-footer strong {
color: #343a40; /* Etwas dunklerer Text für Werte */
.text-gradient {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* --- Restliche Styles --- */
/* Verbessere Lesbarkeit im Log */
#log-output {
max-height: 250px;
overflow-y: auto; /* Vertikales Scrollen bei Bedarf */
overflow-x: auto; /* NEU: Horizontales Scrollen bei Bedarf (Fallback) */
background-color: #f8f8f8;
border: 1px solid #ddd;
padding: 10px;
border-radius: 3px;
font-size: 0.85rem;
/* Input Styling */
.main-input-group {
border-radius: 16px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
#log-content {
white-space: pre-wrap; /* Wichtig: Behält Zeilenumbrüche bei UND erlaubt Umbruch langer Zeilen */
overflow-wrap: break-word;/* Wichtig: Bricht lange Wörter/URLs um, um Überlauf zu verhindern */
word-wrap: break-word; /* Älterer Alias für overflow-wrap */
margin: 0;
font-family: monospace;
/* Entferne explizite Breiten- oder Overflow-Regeln hier, der Container steuert das */
.main-input-group:focus-within {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.3) !important;
}
/* Tabelle responsiver machen */
.table-responsive {
max-height: 500px; /* Höhe begrenzen */
.main-input-group input {
font-size: 1.1rem;
background: white;
}
.main-input-group input:focus {
box-shadow: none;
background: white;
}
#history-table th,
#history-table td {
vertical-align: middle; /* Vertikal zentrieren */
font-size: 0.85rem;
word-break: break-all;
#submit-button {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
border: none;
transition: filter 0.2s;
}
#submit-button:hover {
filter: brightness(110%);
}
#history-table th:nth-child(2), /* Plattform Icon Spalte */
#history-table td:nth-child(2) {
text-align: center;
width: 40px; /* Feste Breite für Icon */
/* Options Animation */
.options-wrapper {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.5s ease-in-out, opacity 0.4s ease-in-out;
}
#history-table td a {
/* Bootstrap übernimmt Link-Styling, Cursor wird per JS gesetzt */
cursor: pointer;
.options-wrapper.show {
max-height: 500px; /* Groß genug */
opacity: 1;
}
/* Kontextmenü (Bootstrap-ähnlich) */
.animate-options {
animation: fadeIn 0.5s ease;
}
/* Glass Button */
.btn-white-glass {
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
color: var(--text-main);
transition: all 0.2s;
}
.btn-white-glass:hover {
background: white;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
/* Progress Bar */
.bg-gradient-primary {
background: linear-gradient(90deg, var(--primary-color), #06b6d4);
background-size: 200% 100%;
animation: gradientMove 2s linear infinite;
}
@keyframes gradientMove {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.animate-pulse {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Helpers */
.hover-lift:hover {
transform: translateY(-3px);
}
.fade-in-up {
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.extra-small {
font-size: 0.75rem;
}
/* Context Menu */
.context-menu {
z-index: 1050; /* Über anderen Elementen */
z-index: 9999;
min-width: 150px;
border-radius: 8px;
overflow: hidden;
border: none;
}
.context-menu .list-group-item {
cursor: pointer;
font-size: 0.9rem;
border: none;
}
.context-menu .list-group-item i {
width: 1.2em; /* Platz für Icons */
}
/* Versteckte Elemente */
.d-none {
display: none !important; /* Wichtig, um Bootstrap zu überschreiben, falls nötig */
}
/* Fortschrittsbalken Text zentrieren */
.progress {
position: relative;
}
.progress-bar {
/* Textfarbe anpassen, falls nötig */
/* color: #212529; */
font-weight: bold;
display: flex; /* Ermöglicht Zentrierung */
justify-content: center;
align-items: center;
overflow: hidden; /* Verhindert, dass Text überläuft */
}
/* Optional: Fehlerhafter Fortschrittsbalken */
.progress-bar.bg-danger {
color: white;
}
/* Kleinere Anpassungen für Radio-Buttons */
.form-check-label i {
margin-right: 4px;
width: 1em; /* Platz für Icon */
}
/* Anpassung für Comboboxen */
#youtube-quality .form-select-sm {
max-width: 180px; /* Breite begrenzen */
display: inline-block; /* Nebeneinander mit Label */
width: auto; /* Automatische Breite */
}
#youtube-quality label {
margin-right: 5px;
}
#mp3-quality-section, #mp4-quality-section {
margin-right: 15px; /* Abstand zwischen Qualitätsoptionen */
}
/* Plattform Icons im Formular */
.form-check-label i.fa-soundcloud { color: #ff5500; }
.form-check-label i.fa-youtube { color: #ff0000; }
.form-check-label i.fa-tiktok { color: #000000; } /* Oder #fe2c55, #00f2ea */
.form-check-label i.fa-instagram { color: #E1306C; } /* Instagram Pink */
.form-check-label i.fa-x-twitter { color: #000000; } /* Twitter/X Schwarz */
.form-check-label i.fa-twitter { color: #1DA1F2; } /* Altes Twitter Blau (Fallback) */
/* Plattform Icons in der History Tabelle */
#history-table td i.fa-soundcloud { color: #ff5500; }
#history-table td i.fa-youtube { color: #ff0000; }
#history-table td i.fa-tiktok { color: #000000; } /* Oder #fe2c55, #00f2ea */
#history-table td i.fa-instagram { color: #E1306C; } /* Instagram Pink */
#history-table td i.fa-x-twitter { color: #000000; } /* Twitter/X Schwarz */
#history-table td i.fa-twitter { color: #1DA1F2; } /* Altes Twitter Blau (Fallback) */
/* Hilfetext für URL-Eingabe */
#urlHelp {
font-size: 0.8rem;
}
/* --- Fullscreen Processing Overlay --- */
#processing-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(10, 25, 47, 0.85); /* Dunkelblauer, transparenter Hintergrund */
backdrop-filter: blur(5px); /* Hintergrund unscharf machen */
-webkit-backdrop-filter: blur(5px); /* Für Safari-Kompatibilität */
z-index: 2000; /* Über allem anderen */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #64ffda; /* Cyan/Türkis für den Text */
font-family: 'Courier New', Courier, monospace;
text-align: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.4s ease, visibility 0.4s ease;
}
#processing-overlay.visible {
opacity: 1;
visibility: visible;
}
#processing-overlay img {
max-width: 90%;
width: 350px;
height: auto;
border-radius: 8px;
box-shadow: 0 0 25px rgba(100, 255, 218, 0.5); /* Passender Leuchteffekt */
margin-bottom: 20px;
}
#processing-overlay #overlay-message {
font-size: 1.5rem;
text-shadow: 0 0 10px #64ffda; /* Leuchteffekt für Text */
padding: 0 20px;
margin-bottom: 25px; /* Mehr Abstand nach unten */
}
/* NEU: Stile für den Status-Container im Overlay */
.overlay-status-container {
width: 80%;
max-width: 600px;
}
#overlay-status-text {
font-size: 1rem;
color: #ccd6f6; /* Heller, aber nicht so grell wie die Hauptfarbe */
margin-bottom: 10px;
min-height: 1.2em; /* Verhindert Springen bei Textänderung */
word-wrap: break-word;
}
/* NEU: Stile für den Fortschrittsbalken im Overlay */
#processing-overlay .progress {
height: 20px;
background-color: rgba(100, 255, 218, 0.1); /* Hintergrund des Balkens */
border: 1px solid rgba(100, 255, 218, 0.3);
border-radius: 5px;
padding: 2px;
}
#processing-overlay .progress-bar {
background-color: #64ffda !important; /* Wichtig, um Bootstrap zu überschreiben */
color: #0a192f; /* Dunkler Text für Kontrast */
font-weight: bold;
transition: width 0.3s ease-in-out; /* Weicherer Übergang */
.context-menu .list-group-item:hover {
background-color: #f3f4f6;
}
+169 -249
View File
@@ -2,267 +2,187 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO Meta Tags -->
<meta name="description" content="Nutzen Sie den MrUnknownDE Socialmedia Downloader, um Videos und Musik von YouTube (MP3/MP4), SoundCloud, TikTok, Instagram Reels und Twitter/X einfach herunterzuladen. Fügen Sie die URL ein und starten Sie den Download.">
<meta name="keywords" content="Social Media Downloader, YouTube Downloader, SoundCloud Downloader, TikTok Downloader, Instagram Reel Downloader, Twitter Downloader, X Downloader, Video Downloader, Audio Downloader, MP3, MP4, Herunterladen, Konvertieren, MrUnknownDE, medien.mrunk.de">
<meta name="author" content="MrUnknownDE">
<meta name="robots" content="index, follow"> <!-- Erlaubt Suchmaschinen das Indexieren und Folgen von Links -->
<!-- Open Graph / Facebook Meta Tags -->
<meta property="og:title" content="MrUnknownDE Socialmedia Downloader - Medien von YouTube, SC, TikTok, IG, Twitter herunterladen">
<meta property="og:description" content="Einfacher Download von Videos und Musik (MP3/MP4) von beliebten Social Media Plattformen wie YouTube, SoundCloud, TikTok, Instagram Reels und Twitter/X.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://medien.mrunk.de/"> <!-- Ersetze dies mit der tatsächlichen URL deiner Seite -->
<meta property="og:image" content="https://medien.mrunk.de/static/og-image.png"> <!-- Ersetze dies mit der URL zu einem Vorschaubild (z.B. 1200x630px) -->
<meta property="og:site_name" content="medien.mrunk.de">
<meta property="og:locale" content="de_DE">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image"> <!-- 'summary_large_image' ist oft ansprechender -->
<meta name="twitter:title" content="MrUnknownDE Socialmedia Downloader - Medien von YouTube, SC, TikTok, IG, Twitter herunterladen">
<meta name="twitter:description" content="Einfacher Download von Videos und Musik (MP3/MP4) von YouTube, SoundCloud, TikTok, Instagram Reels und Twitter/X.">
<meta name="twitter:image" content="https://medien.mrunk.de/static/og-image.png"> <!-- Gleiches Bild wie für Open Graph verwenden -->
<!-- Optional: Füge deinen Twitter-Benutzernamen hinzu -->
<!-- <meta name="twitter:site" content="@DeinTwitterHandle"> -->
<!-- <meta name="twitter:creator" content="@MrUnknownDE"> -->
<title>medien.mrunk.de - Social Media Downloader</title>
<title>medien.mrunk.de - Smart Downloader</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Font Awesome CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" />
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<!-- Hauptinhalts-Container -->
<div class="container mt-4 main-content">
<h1 class="mb-4 text-center"><i class="fas fa-cloud-upload-alt"></i> MrUnknownDE Socialmedia Downloader</h1>
<div class="row g-4">
<!-- Downloader Spalte -->
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header">
<i class="fas fa-upload"></i> Downloader
</div>
<div class="card-body">
<form id="upload-form">
<!-- Plattform Auswahl -->
<div class="mb-3">
<label class="form-label fw-bold">Plattform:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-sc" value="SoundCloud" checked>
<label class="form-check-label" for="platform-sc"><i class="fab fa-soundcloud text-warning"></i> SoundCloud</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-yt" value="YouTube">
<label class="form-check-label" for="platform-yt"><i class="fab fa-youtube text-danger"></i> YouTube</label> <!-- (+ Reels) entfernt für Klarheit -->
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-tt" value="TikTok">
<label class="form-check-label" for="platform-tt"><i class="fab fa-tiktok"></i> TikTok</label>
</div>
<!-- NEU: Instagram Radio Button -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-ig" value="Instagram">
<label class="form-check-label" for="platform-ig"><i class="fab fa-instagram"></i> Instagram Reel</label>
</div>
<!-- NEU: Twitter Radio Button -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="platform" id="platform-tw" value="Twitter">
<!-- Verwende fab fa-x-twitter für das neue Logo oder fab fa-twitter für das alte -->
<label class="form-check-label" for="platform-tw"><i class="fab fa-x-twitter"></i> Twitter/X Video</label>
</div>
</div>
</div>
<!-- Background Shapes for Atmosphere -->
<div class="bg-shape shape-1"></div>
<div class="bg-shape shape-2"></div>
<!-- URL Eingabe -->
<div class="mb-3">
<label for="url" class="form-label fw-bold"><i class="fas fa-link"></i> Quelle URL:</label>
<input type="url" class="form-control" id="url" name="url" required placeholder="https://...">
<small id="urlHelp" class="form-text text-muted">
Für Instagram nur Reel-Links (z.B. .../reel/...), für Twitter/X nur Tweet-Links (z.B. .../status/...).
</small>
</div>
<!-- YouTube Optionen (dynamisch) -->
<div id="youtube-options" class="mb-3 d-none border-top pt-3"> <!-- border-top hinzugefügt für visuelle Trennung -->
<label class="form-label fw-bold">YouTube Optionen:</label>
<div class="mb-2">
<label class="form-label small">Format:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="yt_format" id="format-mp3" value="mp3" checked>
<label class="form-check-label small" for="format-mp3"><i class="fas fa-file-audio text-primary"></i> MP3</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="yt_format" id="format-mp4" value="mp4">
<label class="form-check-label small" for="format-mp4"><i class="fas fa-file-video text-info"></i> MP4</label>
</div>
</div>
</div>
<div id="youtube-quality">
<div id="mp3-quality-section">
<label for="mp3_bitrate" class="form-label small"><i class="fas fa-sliders-h"></i> MP3 Bitrate:</label>
<select class="form-select form-select-sm d-inline-block w-auto" id="mp3_bitrate" name="mp3_bitrate">
<option>Best</option>
<option>256k</option>
<option selected>192k</option>
<option>128k</option>
<option>64k</option>
</select>
</div>
<div id="mp4-quality-section" class="d-none">
<label for="mp4_quality" class="form-label small"><i class="fas fa-photo-video"></i> MP4 Qualität:</label>
<select class="form-select form-select-sm d-inline-block w-auto" id="mp4_quality" name="mp4_quality">
<option selected>Best</option>
<option>Medium (~720p)</option>
<option>Low (~480p)</option>
</select>
</div>
</div>
</div>
<!-- Codec Optionen (dynamisch für YT-MP4, TikTok, Instagram, Twitter) -->
<div id="codec-options-section" class="mb-3 d-none border-top pt-3">
<label class="form-label fw-bold">Video Codec:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="codec_preference" id="codec-original" value="original" checked>
<label class="form-check-label small" for="codec-original"><i class="fas fa-file-video text-info"></i> Original</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="codec_preference" id="codec-h264" value="h264">
<label class="form-check-label small" for="codec-h264"><i class="fas fa-cogs text-secondary"></i> Kompatibel (H.264)</label>
</div>
</div>
<small class="form-text text-muted">
<i class="fas fa-exclamation-triangle text-warning"></i> H.264-Konvertierung kann die Verarbeitung erheblich verlängern.
</small>
</div>
<button type="submit" id="submit-button" class="btn btn-success w-100 mt-3">
<i class="fas fa-cloud-upload-alt"></i> Download starten
</button>
</form>
<!-- Status & Progress -->
<div id="status-area" class="mt-4 pt-3 border-top">
<h5 class="mb-2">Status <span id="queue-info" class="badge bg-secondary ms-2 d-none"></span></h5>
<div class="progress mb-2" role="progressbar" aria-label="Download/Upload Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="height: 25px;">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-info text-dark" style="width: 0%">0%</div>
</div>
<p id="status-message" class="text-muted mb-2">Bereit.</p>
<div id="log-output" class="border rounded p-2 bg-light mb-2">
<h6 class="mb-1">Log:</h6>
<pre id="log-content" class="small"></pre>
</div>
<p id="result-url-area" class="alert alert-success d-none" role="alert">
<i class="fas fa-check-circle"></i>→ Download-Link: <a id="result-url" href="#" target="_blank" class="alert-link"></a>
</p>
<p id="error-message" class="alert alert-danger d-none" role="alert"></p>
</div>
</div>
</div>
</div>
<!-- History Spalte -->
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-history"></i> Verlauf</span>
{# Button nur anzeigen, wenn History aktiviert ist #}
{% if history_enabled %}
<button id="clear-history-button" class="btn btn-sm btn-outline-danger" title="Verlauf löschen">
<i class="fas fa-trash-alt"></i> Löschen
</button>
{% else %}
<span class="badge bg-secondary">Deaktiviert</span>
{% endif %}
</div>
<div class="card-body">
{% if not history_enabled %}
<p class="text-muted text-center">Der Verlaufs-Speicher ist in der Konfiguration deaktiviert.</p>
{% endif %}
<div class="table-responsive">
<table id="history-table" class="table table-striped table-hover table-sm caption-top">
{% if history_enabled %}
<caption>Rechtsklick auf URLs für Optionen</caption>
{% endif %}
<thead class="table-light">
<tr>
<th>Zeit</th>
<th><i class="fas fa-globe"></i></th> <!-- Plattform Icon -->
<th>Titel</th>
<th>Quelle URL</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{# Inhalt wird per JS geladen, aber initiale Meldung kann hier stehen #}
{% if history_enabled %}
<tr><td colspan="5" class="text-center text-muted">Lade Verlauf...</td></tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted">- Verlauf deaktiviert -</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div> <!-- Ende row -->
</div> <!-- Ende Hauptinhalts-Container -->
<!-- Kontextmenü (versteckt) -->
<ul id="context-menu" class="context-menu list-group position-absolute d-none shadow-lg">
<li class="list-group-item list-group-item-action py-2 px-3" data-action="open"><i class="fas fa-external-link-alt me-2"></i>Öffnen</li>
<li class="list-group-item list-group-item-action py-2 px-3" data-action="copy"><i class="fas fa-copy me-2"></i>Kopieren</li>
</ul>
<!-- Footer (jetzt außerhalb des .container) -->
<footer class="container-fluid mt-auto py-3 text-center text-muted small page-footer">
<hr class="container"> <!-- Trennlinie innerhalb der Container-Breite -->
<div class="container"> <!-- Inhalt wieder zentriert -->
<p>
<span title="Gesamtzahl verarbeiteter Aufträge (erfolgreich oder fehlgeschlagen)">
<i class="fas fa-tasks"></i> Aufträge gesamt: <strong id="stats-total-jobs">...</strong>
</span> |
<span title="Durchschnittliche Verarbeitungszeit für erfolgreiche Aufträge">
<i class="fas fa-stopwatch"></i> Ø Dauer: <strong id="stats-avg-duration">...</strong> Sek.
</span> |
<span title="Gesamtgröße aller erfolgreich hochgeladenen Dateien">
<i class="fas fa-database"></i> Gesamtgröße: <strong id="stats-total-size">...</strong>
</span>
</p>
<p>
<i class="fa-solid fa-circle-info"></i> Download-Links werden nach <strong>7 Tage</strong> gelöscht!
</p>
<p>
<i class="fab fa-discord"></i> Kontakt bei Fragen/Problemen: <strong><a href="https://www.discordapp.com/users/155076323612688384">MrUnknownDE</a></strong>
</p>
</div>
</footer>
<!-- Fullscreen Processing Overlay -->
<div id="processing-overlay">
<img src="https://media.giphy.com/media/YQitE4YNQNahy/giphy.gif" alt="Processing Animation" width="300">
<p id="overlay-message">Hacking the mainframe...</p>
<div class="container d-flex flex-column min-vh-100 justify-content-center align-items-center position-relative z-1">
<!-- NEU: Status-Container im Overlay -->
<div class="overlay-status-container">
<p id="overlay-status-text">Initialisiere...</p>
<div class="progress" role="progressbar" aria-label="Overlay Progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="overlay-progress-bar" class="progress-bar" style="width: 0%">0%</div>
<!-- Hero Section -->
<div class="hero-card text-center fade-in-up">
<h1 class="display-4 fw-bold mb-2 text-gradient">Media Downloader</h1>
<p class="text-muted mb-4 lead">Füge einfach deinen Link ein. Wir erledigen den Rest.</p>
<form id="upload-form">
<!-- Hidden Platform Input (Auto-Detected) -->
<input type="hidden" name="platform" id="input-platform" value="SoundCloud">
<!-- Main Input Group -->
<div class="input-group input-group-lg shadow-sm mb-3 main-input-group">
<span class="input-group-text bg-white border-0 ps-4">
<i class="fas fa-link text-muted" id="url-icon"></i>
</span>
<input type="url" class="form-control border-0 py-3" id="url" name="url" required placeholder="https://soundcloud.com/..." autocomplete="off">
<button class="btn btn-primary px-4 fw-bold" type="submit" id="submit-button">
<i class="fas fa-arrow-right"></i>
</button>
</div>
<!-- Detected Platform Badge (Animated) -->
<div id="platform-badge-container" class="mb-3" style="height: 30px; opacity: 0; transition: all 0.3s ease;">
<span class="badge rounded-pill bg-light text-dark border px-3 py-2">
<i id="detected-icon" class="fas fa-question-circle me-1"></i>
<span id="detected-text">Warte auf Link...</span>
</span>
</div>
<!-- Options Container (Collapsible) -->
<div id="options-container" class="options-wrapper">
<div class="card card-body border-0 shadow-sm bg-white-50 mt-2 text-start">
<!-- YouTube Options -->
<div id="youtube-options" class="d-none animate-options">
<h6 class="fw-bold text-uppercase small text-muted mb-3"><i class="fab fa-youtube text-danger"></i> YouTube Einstellungen</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small fw-bold">Format</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="yt_format" id="format-mp3" value="mp3" checked>
<label class="btn btn-outline-primary btn-sm" for="format-mp3">Audio (MP3)</label>
<input type="radio" class="btn-check" name="yt_format" id="format-mp4" value="mp4">
<label class="btn btn-outline-primary btn-sm" for="format-mp4">Video (MP4)</label>
</div>
</div>
<div class="col-md-6" id="quality-wrapper">
<label class="form-label small fw-bold">Qualität</label>
<!-- MP3 Select -->
<select class="form-select form-select-sm" id="mp3_bitrate" name="mp3_bitrate">
<option>Best</option>
<option selected>192k</option>
<option>128k</option>
</select>
<!-- MP4 Select -->
<select class="form-select form-select-sm d-none" id="mp4_quality" name="mp4_quality">
<option selected>Best</option>
<option>Medium (~720p)</option>
<option>Low (~480p)</option>
</select>
</div>
</div>
</div>
<!-- Video Codec Options (Shared) -->
<div id="codec-options-section" class="d-none animate-options mt-3 pt-3 border-top">
<div class="d-flex justify-content-between align-items-center">
<div>
<label class="form-label small fw-bold mb-0">Kompatibilitäts-Modus</label>
<div class="text-muted extra-small" style="font-size: 0.75rem;">Konvertiert zu H.264 (für WhatsApp/alte Geräte).</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="codec-switch">
<input type="hidden" name="codec_preference" id="codec_preference" value="original">
</div>
</div>
</div>
</div>
</div>
<!-- Error Message -->
<div id="error-message" class="alert alert-danger mt-3 d-none shadow-sm text-start" role="alert"></div>
</form>
<!-- Processing / Progress View -->
<div id="progress-container" class="d-none mt-4 text-center">
<h5 id="status-message" class="fw-bold mb-3 animate-pulse">Starte...</h5>
<div class="progress" style="height: 10px; border-radius: 10px;">
<div id="progress-bar" class="progress-bar bg-gradient-primary" role="progressbar" style="width: 0%"></div>
</div>
<div id="log-content" class="text-muted mt-2 extra-small text-truncate" style="max-width: 100%;">Details...</div>
</div>
<!-- Result View -->
<div id="result-container" class="d-none mt-4">
<div class="success-checkmark mb-3">
<i class="fas fa-check-circle text-success display-4"></i>
</div>
<h4 class="fw-bold">Fertig!</h4>
<a id="result-url" href="#" target="_blank" class="btn btn-success btn-lg mt-2 shadow-lg hover-lift">
<i class="fas fa-download me-2"></i> Datei herunterladen
</a>
<button class="btn btn-link text-muted mt-3 d-block mx-auto" onclick="location.reload()">
Weitere Datei laden
</button>
</div>
</div>
<!-- Footer Links -->
<div class="mt-4 text-center">
<button class="btn btn-sm btn-white-glass rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#historyModal">
<i class="fas fa-history me-1"></i> Verlauf ansehen
</button>
<div class="mt-3 text-muted extra-small opacity-75">
&copy; 2025 MrUnknownDE &bull; Links gültig für 7 Tage
</div>
</div>
</div>
<!-- History Modal -->
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 20px; overflow: hidden;">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title fw-bold"><i class="fas fa-history me-2"></i>Verlauf</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div class="table-responsive" style="max-height: 400px;">
<table id="history-table" class="table table-hover mb-0 align-middle">
<thead class="table-light sticky-top">
<tr>
<th>Zeit</th>
<th class="text-center">Plattform</th>
<th>Titel</th>
<th class="text-end">Link</th>
</tr>
</thead>
<tbody class="border-top-0">
<!-- JS fills this -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer border-0 bg-light justify-content-between">
<button id="clear-history-button" class="btn btn-outline-danger btn-sm">Alles löschen</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<!-- Custom JS -->
<!-- Context Menu -->
<ul id="context-menu" class="context-menu list-group position-absolute d-none shadow-lg">
<li class="list-group-item list-group-item-action py-2 px-3" data-action="copy"><i class="fas fa-copy me-2"></i>Link kopieren</li>
</ul>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>