Spaces:
Sleeping
Sleeping
| <!-- app/static/index.html --> | |
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Pro Video Downloader</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| .step-indicator { @apply relative flex items-center px-4 py-2 text-sm font-medium text-gray-500 rounded-full bg-gray-100 dark:bg-gray-800 dark:text-gray-400; } | |
| .step-indicator.active { @apply bg-blue-600 text-white; } | |
| .step-indicator.done { @apply bg-green-600 text-white; } | |
| .step-connector { @apply absolute left-full w-8 h-0.5 bg-gray-200 dark:bg-gray-700; } | |
| .progress-bar { transition: width 0.3s ease-out; } | |
| .format-option input:checked + div { @apply border-blue-600 bg-blue-50 dark:bg-blue-900/20; } | |
| .log-entry { @apply text-xs font-mono px-2 py-1 rounded; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col"> | |
| <header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-10"> | |
| <div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between"> | |
| <h1 class="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-2"> | |
| <svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg> | |
| Pro Downloader | |
| </h1> | |
| <span class="text-xs text-gray-500 px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">yt-dlp + FastAPI</span> | |
| </div> | |
| </header> | |
| <main class="flex-1 max-w-4xl w-full mx-auto px-4 py-8 flex flex-col items-center"> | |
| <!-- Stepper --> | |
| <div class="w-full mb-8 flex items-center justify-center relative" id="stepper"> | |
| <div class="step-indicator active" data-step="1"><span>1. Lien</span></div> | |
| <div class="step-connector"></div> | |
| <div class="step-indicator" data-step="2"><span>2. Options</span></div> | |
| <div class="step-connector"></div> | |
| <div class="step-indicator" data-step="3"><span>3. Téléchargement</span></div> | |
| </div> | |
| <!-- ÉTAPE 1 --> | |
| <section id="step-1-view" class="w-full max-w-2xl transition-opacity duration-300"> | |
| <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6 md:p-8"> | |
| <div class="text-center mb-6"> | |
| <h2 class="text-xl font-semibold text-gray-800 dark:text-white">Étape 1 : Analyse du lien</h2> | |
| <p class="text-gray-500 dark:text-gray-400 mt-1">Collez l'URL (YouTube, X/Twitter, Vimeo, Facebook, TikTok, Generic m3u8...)</p> | |
| </div> | |
| <div class="flex gap-2"> | |
| <input type="url" id="urlInput" placeholder="https://youtube.com/watch?v=... ou https://x.com/.../status/..." | |
| class="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" | |
| autocomplete="off"> | |
| <button id="analyzeBtn" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <svg id="analyzeSpinner" class="w-5 h-5 animate-spin hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> | |
| <span id="analyzeText">Analyser</span> | |
| </button> | |
| </div> | |
| <p id="urlError" class="text-red-500 text-sm mt-3 min-h-[20px] hidden"></p> | |
| </div> | |
| </section> | |
| <!-- ÉTAPE 2 --> | |
| <section id="step-2-view" class="w-full max-w-2xl hidden transition-opacity duration-300"> | |
| <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6 md:p-8"> | |
| <!-- Meta --> | |
| <div class="flex gap-4 mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700"> | |
| <img id="thumbImg" src="" alt="Thumbnail" class="w-24 h-24 object-cover rounded-lg flex-shrink-0 border border-gray-200 dark:border-gray-700"> | |
| <div class="flex-1 min-w-0"> | |
| <h3 id="videoTitle" class="font-semibold text-gray-800 dark:text-white truncate">Titre de la vidéo</h3> | |
| <div class="flex flex-wrap gap-4 mt-2 text-sm text-gray-500 dark:text-gray-400"> | |
| <span id="videoDuration" class="flex items-center gap-1"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> --:--</span> | |
| <span id="videoUploader" class="flex items-center gap-1"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg> Inconnu</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Type Choice --> | |
| <fieldset class="mb-6"> | |
| <legend class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Type de téléchargement</legend> | |
| <div class="flex gap-4"> | |
| <label class="flex-1 flex items-center justify-center gap-2 p-4 border-2 rounded-xl cursor-pointer transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-blue-400 has-[:checked]:border-blue-600 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20"> | |
| <input type="radio" name="dlType" value="video" class="sr-only peer" checked> | |
| <svg class="w-6 h-6 text-gray-400 peer-checked:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| <span class="font-medium text-gray-700 dark:text-gray-200">Vidéo + Son</span> | |
| </label> | |
| <label class="flex-1 flex items-center justify-center gap-2 p-4 border-2 rounded-xl cursor-pointer transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-blue-400 has-[:checked]:border-blue-600 has-[:checked]:bg-blue-50 dark:has-[:checked]:bg-blue-900/20"> | |
| <input type="radio" name="dlType" value="audio" class="sr-only peer"> | |
| <svg class="w-6 h-6 text-gray-400 peer-checked:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1a1 1 0 011 1v3.586a1 1 0 01-.293.707l-5.586 5.586a1 1 0 01-1.414 0z"></path></svg> | |
| <span class="font-medium text-gray-700 dark:text-gray-200">Audio Only (MP3)</span> | |
| </label> | |
| </div> | |
| </fieldset> | |
| <!-- Format Selection --> | |
| <fieldset class="mb-6"> | |
| <div class="flex justify-between items-center mb-3"> | |
| <legend class="text-sm font-medium text-gray-700 dark:text-gray-300">Qualité / Format <span id="formatCount" class="text-gray-400 font-normal"></span></legend> | |
| <!-- BOUTON AUTO GLOBAL --> | |
| <button id="autoSelectBtn" class="hidden px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition flex items-center gap-1"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg> | |
| Auto (Recommandé) | |
| </button> | |
| </div> | |
| <div id="formatList" class="space-y-2 max-h-72 overflow-y-auto pr-2 border border-gray-200 dark:border-gray-700 rounded-xl p-2 bg-gray-50 dark:bg-gray-800"> | |
| <!-- Injecté par JS --> | |
| </div> | |
| <!-- Message si vide --> | |
| <div id="noFormatMsg" class="hidden text-center py-6 text-gray-500 dark:text-gray-400 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-xl"> | |
| <svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| <p class="mb-2">Aucun format standard détecté pour ce lien.</p> | |
| <button id="forceAutoBtn" class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition">Forcer "Best Quality" (Auto)</button> | |
| </div> | |
| </fieldset> | |
| <!-- Actions --> | |
| <div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700"> | |
| <button id="backBtn" class="px-4 py-2.5 text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl font-medium transition">Retour</button> | |
| <button id="startDownloadBtn" disabled class="px-6 py-2.5 bg-green-600 hover:bg-green-700 text-white font-medium rounded-xl transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg> | |
| Lancer le téléchargement | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ÉTAPE 3 --> | |
| <section id="step-3-view" class="w-full max-w-2xl hidden transition-opacity duration-300"> | |
| <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6 md:p-8"> | |
| <div class="text-center mb-6"> | |
| <h2 class="text-xl font-semibold text-gray-800 dark:text-white">Téléchargement en cours</h2> | |
| <p class="text-gray-500 dark:text-gray-400 mt-1 truncate" id="dlFileName" title="">Préparation...</p> | |
| </div> | |
| <div class="mb-6"> | |
| <div class="flex justify-between text-sm mb-1"> | |
| <span id="dlPercent" class="font-mono font-bold text-blue-600">0%</span> | |
| <span id="dlSpeed" class="text-gray-500 dark:text-gray-400">0 Mo/s</span> | |
| </div> | |
| <div class="h-3 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden"> | |
| <div id="dlBar" class="progress-bar h-full bg-blue-600 rounded-full" style="width: 0%"></div> | |
| </div> | |
| <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1"> | |
| <span id="dlSize">0 / 0 Mo</span> | |
| <span id="dlEta">ETA: --:--</span> | |
| </div> | |
| </div> | |
| <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 h-48 overflow-y-auto font-mono text-sm text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700" id="dlLog"> | |
| <div class="text-gray-400 log-entry">En attente de connexion...</div> | |
| </div> | |
| <!-- Zone Final --> | |
| <div id="dlCompleteActions" class="hidden mt-6 space-y-4"> | |
| <div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl text-center"> | |
| <svg class="w-12 h-12 mx-auto text-green-600 dark:text-green-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| <h3 class="font-semibold text-green-800 dark:text-green-200">Prêt à être enregistré</h3> | |
| <p class="text-sm text-green-700 dark:text-green-300 mt-1 truncate" id="finalFileNameDisplay">video.mp4</p> | |
| </div> | |
| <a id="finalDownloadLink" href="#" class="block w-full text-center px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-xl transition text-lg flex items-center justify-center gap-2"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg> | |
| Télécharger sur l'appareil | |
| </a> | |
| <button id="newDownloadBtn" class="w-full px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm">Nouveau téléchargement</button> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <footer class="py-6 px-4 text-center text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-800"> | |
| Propulsé par <a href="https://github.com/yt-dlp/yt-dlp" target="_blank" class="underline hover:text-blue-600">yt-dlp</a> & <a href="https://fastapi.tiangolo.com" target="_blank" class="underline hover:text-blue-600">FastAPI</a> — Fichiers auto-supprimés après 30 min | |
| </footer> | |
| <script> | |
| /* ============================================================ | |
| FRONTEND LOGIC | |
| ============================================================ */ | |
| // State | |
| let ws = null; | |
| let currentFormats = []; | |
| let selectedFormat = null; | |
| let isAudioOnly = false; | |
| // DOM | |
| const views = { 1: document.getElementById('step-1-view'), 2: document.getElementById('step-2-view'), 3: document.getElementById('step-3-view') }; | |
| const steppers = document.querySelectorAll('#stepper .step-indicator'); | |
| const urlInput = document.getElementById('urlInput'); | |
| const analyzeBtn = document.getElementById('analyzeBtn'); | |
| const analyzeSpinner = document.getElementById('analyzeSpinner'); | |
| const analyzeText = document.getElementById('analyzeText'); | |
| const urlError = document.getElementById('urlError'); | |
| const videoTitle = document.getElementById('videoTitle'); | |
| const videoDuration = document.getElementById('videoDuration'); | |
| const videoUploader = document.getElementById('videoUploader'); | |
| const thumbImg = document.getElementById('thumbImg'); | |
| const formatList = document.getElementById('formatList'); | |
| const formatCount = document.getElementById('formatCount'); | |
| const autoSelectBtn = document.getElementById('autoSelectBtn'); | |
| const noFormatMsg = document.getElementById('noFormatMsg'); | |
| const forceAutoBtn = document.getElementById('forceAutoBtn'); | |
| const backBtn = document.getElementById('backBtn'); | |
| const startDownloadBtn = document.getElementById('startDownloadBtn'); | |
| const dlTypeRadios = document.querySelectorAll('input[name="dlType"]'); | |
| // Step 3 | |
| const dlBar = document.getElementById('dlBar'); | |
| const dlPercent = document.getElementById('dlPercent'); | |
| const dlSpeed = document.getElementById('dlSpeed'); | |
| const dlSize = document.getElementById('dlSize'); | |
| const dlEta = document.getElementById('dlEta'); | |
| const dlLog = document.getElementById('dlLog'); | |
| const dlFileName = document.getElementById('dlFileName'); | |
| const dlCompleteActions = document.getElementById('dlCompleteActions'); | |
| const finalDownloadLink = document.getElementById('finalDownloadLink'); | |
| const finalFileNameDisplay = document.getElementById('finalFileNameDisplay'); | |
| const newDownloadBtn = document.getElementById('newDownloadBtn'); | |
| // Utils | |
| const formatTime = (sec) => { if (!sec || sec < 0) return '--:--'; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); return (h > 0 ? `${h}:` : '') + `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; | |
| const formatBytes = (bytes) => { if (!bytes) return '?'; const k=1024, sizes=['o','Ko','Mo','Go']; const i=Math.floor(Math.log(bytes)/Math.log(k)); return parseFloat((bytes/Math.pow(k,i)).toFixed(1))+' '+sizes[i]; }; | |
| const showView = (step) => { Object.values(views).forEach(v=>v.classList.add('hidden')); views[step].classList.remove('hidden'); steppers.forEach((s,i)=>{s.classList.remove('active','done'); if(i+1<step)s.classList.add('done'); else if(i+1===step)s.classList.add('active');}); window.scrollTo({top:0, behavior:'smooth'}); }; | |
| const setAnalyzing = (b) => { analyzeBtn.disabled=b; analyzeSpinner.classList.toggle('hidden',!b); analyzeText.textContent=b?'Analyse...':'Analyser'; }; | |
| const showError = (msg) => { urlError.textContent=msg; urlError.classList.remove('hidden'); }; | |
| const hideError = () => urlError.classList.add('hidden'); | |
| const log = (msg, type='info') => { const colors={info:'text-gray-600 dark:text-gray-300',success:'text-green-600 dark:text-green-400',error:'text-red-600 dark:text-red-400',warn:'text-yellow-600 dark:text-yellow-400'}; const time=new Date().toLocaleTimeString(); const el=document.createElement('div'); el.className=`log-entry ${colors[type]}`; el.innerHTML=`<span class="text-gray-400">[${time}]</span> ${msg}`; dlLog.appendChild(el); dlLog.scrollTop=dlLog.scrollHeight; }; | |
| // 1. Analyze | |
| analyzeBtn.addEventListener('click', async () => { | |
| const url = urlInput.value.trim(); | |
| if (!url) return showError("Veuillez entrer une URL."); | |
| try { new URL(url); } catch { return showError("URL invalide."); } | |
| setAnalyzing(true); hideError(); | |
| try { | |
| const res = await fetch('/api/analyze', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({url}) }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Erreur analyse"); | |
| currentFormats = data.formats || []; | |
| renderMeta(data); | |
| renderFormats(currentFormats); | |
| showView(2); | |
| } catch (e) { showError(e.message); } | |
| finally { setAnalyzing(false); } | |
| }); | |
| // 2. Render Meta | |
| function renderMeta(meta) { | |
| videoTitle.textContent = meta.title; | |
| videoDuration.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> ${formatTime(meta.duration)}`; | |
| videoUploader.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg> ${meta.uploader || 'Inconnu'}`; | |
| thumbImg.src = meta.thumbnail; thumbImg.alt = meta.title; | |
| } | |
| // 3. Render Formats | |
| function renderFormats(formats) { | |
| formatList.innerHTML = ''; | |
| noFormatMsg.classList.add('hidden'); | |
| autoSelectBtn.classList.add('hidden'); | |
| if (!formats || formats.length === 0) { | |
| noFormatMsg.classList.remove('hidden'); | |
| formatCount.textContent = `(0)`; | |
| startDownloadBtn.disabled = true; | |
| return; | |
| } | |
| // Filtrer selon Type (Video vs Audio) | |
| const videoFormats = formats.filter(f => f.vcodec !== 'none' || f.is_auto); // Auto inclut vidéo | |
| const audioFormats = formats.filter(f => f.acodec !== 'none' && f.vcodec === 'none' || (f.is_auto && f.resolution.includes('Audio'))); | |
| const displayFormats = isAudioOnly ? audioFormats : videoFormats; | |
| formatCount.textContent = `(${displayFormats.length} dispo)`; | |
| if (displayFormats.length === 0) { | |
| noFormatMsg.classList.remove('hidden'); | |
| startDownloadBtn.disabled = true; | |
| return; | |
| } | |
| // Afficher Bouton Auto si il y a des formats "is_auto" | |
| const hasAuto = displayFormats.some(f => f.is_auto); | |
| if (hasAuto) autoSelectBtn.classList.remove('hidden'); | |
| displayFormats.forEach((f, i) => { | |
| const isAuto = f.is_auto; | |
| const sizeStr = f.filesize_mb > 0 ? `~${f.filesize_mb} Mo` : `<span class="text-gray-400">Taille estimée...</span>`; | |
| const badge = isAuto ? `<span class="ml-2 px-1.5 py-0.5 text-xs font-bold text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-300 rounded">Auto</span>` : ''; | |
| const el = document.createElement('label'); | |
| el.className = 'format-option block'; | |
| el.innerHTML = ` | |
| <input type="radio" name="format" value="${f.format_id}" class="sr-only peer" data-format='${JSON.stringify(f)}' ${i === 0 ? 'checked' : ''}> | |
| <div class="p-3 border-2 rounded-xl cursor-pointer transition border-gray-200 dark:border-gray-700 hover:border-blue-400 peer-checked:border-blue-600 peer-checked:bg-blue-50 dark:peer-checked:bg-blue-900/20 flex items-center justify-between gap-3"> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex items-center gap-2 font-medium text-gray-800 dark:text-white truncate">${f.resolution}${badge}</div> | |
| <div class="text-xs text-gray-500 dark:text-gray-400 truncate">${f.note} • ${f.ext.toUpperCase()}</div> | |
| </div> | |
| <div class="text-right text-sm font-mono font-semibold text-gray-700 dark:text-gray-300 whitespace-nowrap">${sizeStr}</div> | |
| </div> | |
| `; | |
| formatList.appendChild(el); | |
| }); | |
| updateSelection(); | |
| } | |
| // Type Change (Video/Audio) | |
| dlTypeRadios.forEach(r => r.addEventListener('change', (e) => { | |
| isAudioOnly = e.target.value === 'audio'; | |
| renderFormats(currentFormats); | |
| })); | |
| // Auto Select Button | |
| autoSelectBtn.addEventListener('click', () => { | |
| const autoFmt = currentFormats.find(f => f.is_auto && (isAudioOnly ? f.resolution.includes('Audio') : f.resolution.includes('p'))); | |
| if (autoFmt) { | |
| document.querySelector(`input[name="format"][value="${autoFmt.format_id}"]`)?.click(); | |
| } | |
| }); | |
| // Force Auto Button (Fallback) | |
| forceAutoBtn.addEventListener('click', () => { | |
| selectedFormat = { format_id: "bestvideo+bestaudio/best", ext: "mp4", resolution: "Forcé Best", is_auto: true }; | |
| startDownloadBtn.disabled = false; | |
| startDownloadBtn.click(); // Lance direct | |
| }); | |
| // Selection Logic | |
| function updateSelection() { | |
| const checked = document.querySelector('input[name="format"]:checked'); | |
| if (checked) { | |
| selectedFormat = JSON.parse(checked.dataset.format); | |
| startDownloadBtn.disabled = false; | |
| } else { selectedFormat = null; startDownloadBtn.disabled = true; } | |
| } | |
| formatList.addEventListener('change', (e) => { if(e.target.name==='format') updateSelection(); }); | |
| // Back | |
| backBtn.addEventListener('click', () => showView(1)); | |
| // 4. Start Download | |
| startDownloadBtn.addEventListener('click', async () => { | |
| if (!selectedFormat && !forceAutoBtn.classList.contains('hidden')) return; // Safety | |
| const url = urlInput.value.trim(); | |
| const audioOnly = isAudioOnly; // Bool | |
| showView(3); | |
| dlCompleteActions.classList.add('hidden'); | |
| dlBar.style.width = '0%'; dlPercent.textContent = '0%'; | |
| dlLog.innerHTML = ''; dlFileName.textContent = `Connexion...`; | |
| connectWebSocket(url, selectedFormat?.format_id || "bestvideo+bestaudio/best", audioOnly); | |
| }); | |
| // WebSocket | |
| function connectWebSocket(url, formatId, audioOnly) { | |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| ws = new WebSocket(`${proto}//${location.host}/api/download`); | |
| ws.onopen = () => { | |
| log('Connexion établie. Envoi config...', 'success'); | |
| ws.send(JSON.stringify({ url, format_id: formatId, audio_only: audioOnly })); | |
| }; | |
| ws.onmessage = (event) => { | |
| const msg = JSON.parse(event.data); | |
| handleWsMessage(msg); | |
| }; | |
| ws.onerror = (err) => log('Erreur WS: ' + err.message, 'error'); | |
| ws.onclose = () => log('Connexion fermée.', 'warn'); | |
| } | |
| function handleWsMessage(msg) { | |
| switch(msg.stage) { | |
| case 'downloading': | |
| const pct = parseFloat(msg.percent.replace('%','')) || 0; | |
| dlBar.style.width = `${pct}%`; | |
| dlPercent.textContent = msg.percent; | |
| dlSpeed.textContent = `${msg.speed_mbps.toFixed(2)} Mo/s`; | |
| dlSize.textContent = `${msg.downloaded_mb.toFixed(1)} / ${msg.total_mb.toFixed(1)} Mo`; | |
| dlEta.textContent = `ETA: ${formatTime(msg.eta_sec)}`; | |
| if (pct % 10 < 0.5) log(`DL: ${msg.percent} (${msg.speed_mbps.toFixed(1)} Mo/s)`, 'info'); | |
| break; | |
| case 'post_processing': | |
| dlBar.style.width = '99%'; dlPercent.textContent = '99%'; | |
| dlFileName.textContent = 'Fusion / Conversion (FFmpeg)...'; | |
| log('Post-traitement (FFmpeg)...', 'warn'); | |
| break; | |
| case 'complete': | |
| dlBar.style.width = '100%'; dlPercent.textContent = '100%'; | |
| dlSpeed.textContent = 'Terminé'; | |
| dlFileName.textContent = msg.filename; | |
| log(`Fichier prêt: ${msg.filename}`, 'success'); | |
| finalDownloadLink.href = msg.download_url; | |
| finalDownloadLink.download = msg.filename; // Force nom fichier | |
| finalFileNameDisplay.textContent = msg.filename; | |
| finalFileNameDisplay.title = msg.filename; | |
| dlCompleteActions.classList.remove('hidden'); | |
| if(ws) { ws.close(); ws=null; } | |
| break; | |
| case 'error': | |
| log(`ERREUR: ${msg.info}`, 'error'); | |
| dlFileName.textContent = 'Échec'; | |
| if(ws) { ws.close(); ws=null; } | |
| break; | |
| } | |
| } | |
| // New Download | |
| newDownloadBtn.addEventListener('click', () => { | |
| urlInput.value = ''; | |
| selectedFormat = null; | |
| currentFormats = []; | |
| showView(1); | |
| }); | |
| // Init | |
| document.addEventListener('DOMContentLoaded', () => urlInput.focus()); | |
| </script> | |
| </body> | |
| </html> |