NathMen12 commited on
Commit
5700c19
·
verified ·
1 Parent(s): 4a41603

Create app/static/index.html

Browse files
Files changed (1) hide show
  1. app/static/index.html +440 -0
app/static/index.html ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pro Video Downloader</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
+ <style>
12
+ body { font-family: 'Inter', sans-serif; }
13
+ .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; }
14
+ .step-indicator.active { @apply bg-blue-600 text-white; }
15
+ .step-indicator.done { @apply bg-green-600 text-white; }
16
+ .step-connector { @apply absolute left-full w-8 h-0.5 bg-gray-200 dark:bg-gray-700; }
17
+ .progress-bar { transition: width 0.3s ease-out; }
18
+ @keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.5); opacity: 0; } }
19
+ .animate-pulse-ring::after { content: ''; position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; border-radius: 50%; border: 2px solid currentColor; transform: translate(-50%, -50%); animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
20
+ </style>
21
+ </head>
22
+ <body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col">
23
+
24
+ <!-- Header -->
25
+ <header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-10">
26
+ <div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
27
+ <h1 class="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-2">
28
+ <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>
29
+ Pro Downloader
30
+ </h1>
31
+ <span class="text-xs text-gray-500 px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">yt-dlp + FastAPI</span>
32
+ </div>
33
+ </header>
34
+
35
+ <main class="flex-1 max-w-4xl w-full mx-auto px-4 py-8 flex flex-col items-center">
36
+
37
+ <!-- Progress Steps Header -->
38
+ <div class="w-full mb-8 flex items-center justify-center relative" id="stepper">
39
+ <div class="step-indicator active" data-step="1"><span>1. Lien</span></div>
40
+ <div class="step-connector"></div>
41
+ <div class="step-indicator" data-step="2"><span>2. Options</span></div>
42
+ <div class="step-connector"></div>
43
+ <div class="step-indicator" data-step="3"><span>3. Téléchargement</span></div>
44
+ </div>
45
+
46
+ <!-- ÉTAPE 1: URL INPUT -->
47
+ <section id="step-1-view" class="w-full max-w-2xl transition-opacity duration-300">
48
+ <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">
49
+ <div class="text-center mb-6">
50
+ <h2 class="text-xl font-semibold text-gray-800 dark:text-white">Étape 1 : Analyse du lien</h2>
51
+ <p class="text-gray-500 dark:text-gray-400 mt-1">Collez l'URL de la vidéo (YouTube, Twitter/X, Vimeo, Facebook, etc.)</p>
52
+ </div>
53
+ <div class="flex gap-2">
54
+ <input type="url" id="urlInput" placeholder="https://youtube.com/watch?v=... ou https://x.com/.../status/..."
55
+ 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"
56
+ autocomplete="off">
57
+ <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">
58
+ <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>
59
+ <span id="analyzeText">Analyser</span>
60
+ </button>
61
+ </div>
62
+ <p id="urlError" class="text-red-500 text-sm mt-3 min-h-[20px] hidden"></p>
63
+ </div>
64
+ </section>
65
+
66
+ <!-- ÉTAPE 2: OPTIONS (Cachée au début) -->
67
+ <section id="step-2-view" class="w-full max-w-2xl hidden transition-opacity duration-300">
68
+ <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">
69
+ <!-- Video Meta -->
70
+ <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">
71
+ <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">
72
+ <div class="flex-1 min-w-0">
73
+ <h3 id="videoTitle" class="font-semibold text-gray-800 dark:text-white truncate">Titre de la vidéo</h3>
74
+ <div class="flex flex-wrap gap-4 mt-2 text-sm text-gray-500 dark:text-gray-400">
75
+ <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>
76
+ <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>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Type Choice -->
82
+ <fieldset class="mb-6">
83
+ <legend class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Type de téléchargement</legend>
84
+ <div class="flex gap-4">
85
+ <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">
86
+ <input type="radio" name="dlType" value="video" class="sr-only peer" checked>
87
+ <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>
88
+ <span class="font-medium text-gray-700 dark:text-gray-200">Vidéo + Son</span>
89
+ </label>
90
+ <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">
91
+ <input type="radio" name="dlType" value="audio" class="sr-only peer">
92
+ <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>
93
+ <span class="font-medium text-gray-700 dark:text-gray-200">Audio Only (MP3)</span>
94
+ </label>
95
+ </div>
96
+ </fieldset>
97
+
98
+ <!-- Format Selection -->
99
+ <fieldset class="mb-6">
100
+ <legend class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Qualité / Format <span id="formatCount" class="text-gray-400 font-normal"></span></legend>
101
+ <div id="formatList" class="space-y-2 max-h-60 overflow-y-auto pr-2">
102
+ <!-- Injecté par JS -->
103
+ </div>
104
+ </fieldset>
105
+
106
+ <!-- Actions -->
107
+ <div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
108
+ <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>
109
+ <button id="startDownloadBtn" 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">
110
+ <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>
111
+ Lancer le téléchargement
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </section>
116
+
117
+ <!-- ÉTAPE 3: DOWNLOAD PROGRESS (Cachée au début) -->
118
+ <section id="step-3-view" class="w-full max-w-2xl hidden transition-opacity duration-300">
119
+ <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">
120
+ <div class="text-center mb-6">
121
+ <h2 class="text-xl font-semibold text-gray-800 dark:text-white">Téléchargement en cours</h2>
122
+ <p class="text-gray-500 dark:text-gray-400 mt-1" id="dlFileName">Préparation...</p>
123
+ </div>
124
+
125
+ <!-- Progress Bar -->
126
+ <div class="mb-6">
127
+ <div class="flex justify-between text-sm mb-1">
128
+ <span id="dlPercent" class="font-mono font-bold text-blue-600">0%</span>
129
+ <span id="dlSpeed" class="text-gray-500 dark:text-gray-400">0 MB/s</span>
130
+ </div>
131
+ <div class="h-3 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden">
132
+ <div id="dlBar" class="progress-bar h-full bg-blue-600 rounded-full" style="width: 0%"></div>
133
+ </div>
134
+ <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
135
+ <span id="dlSize">0 / 0 MB</span>
136
+ <span id="dlEta">ETA: --:--</span>
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Steps Log -->
141
+ <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 h-40 overflow-y-auto font-mono text-sm text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700" id="dlLog">
142
+ <div class="text-gray-400">En attente de connexion...</div>
143
+ </div>
144
+
145
+ <!-- Final Action -->
146
+ <div id="dlCompleteActions" class="hidden mt-6 flex flex-col gap-3 items-center">
147
+ <div class="w-full flex justify-center">
148
+ <a id="finalDownloadLink" href="#" class="inline-flex items-center gap-2 px-8 py-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-xl transition text-lg">
149
+ <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>
150
+ Enregistrer le fichier
151
+ </a>
152
+ </div>
153
+ <button id="newDownloadBtn" class="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>
154
+ </div>
155
+ </div>
156
+ </section>
157
+
158
+ </main>
159
+
160
+ <!-- Footer -->
161
+ <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">
162
+ 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> — Hébergé sur Hugging Face Spaces
163
+ </footer>
164
+
165
+ <script>
166
+ /* ============================================================
167
+ FRONTEND LOGIC (Vanilla JS - Zero Deps)
168
+ ============================================================ */
169
+
170
+ // --- State ---
171
+ let ws = null;
172
+ let currentSessionId = null;
173
+ let selectedFormat = null;
174
+
175
+ // --- DOM Elements ---
176
+ const views = {
177
+ 1: document.getElementById('step-1-view'),
178
+ 2: document.getElementById('step-2-view'),
179
+ 3: document.getElementById('step-3-view')
180
+ };
181
+ const steppers = document.querySelectorAll('#stepper .step-indicator');
182
+
183
+ const urlInput = document.getElementById('urlInput');
184
+ const analyzeBtn = document.getElementById('analyzeBtn');
185
+ const analyzeSpinner = document.getElementById('analyzeSpinner');
186
+ const analyzeText = document.getElementById('analyzeText');
187
+ const urlError = document.getElementById('urlError');
188
+
189
+ const videoTitle = document.getElementById('videoTitle');
190
+ const videoDuration = document.getElementById('videoDuration');
191
+ const videoUploader = document.getElementById('videoUploader');
192
+ const thumbImg = document.getElementById('thumbImg');
193
+ const formatList = document.getElementById('formatList');
194
+ const formatCount = document.getElementById('formatCount');
195
+ const backBtn = document.getElementById('backBtn');
196
+ const startDownloadBtn = document.getElementById('startDownloadBtn');
197
+ const dlTypeRadios = document.querySelectorAll('input[name="dlType"]');
198
+
199
+ // Step 3
200
+ const dlBar = document.getElementById('dlBar');
201
+ const dlPercent = document.getElementById('dlPercent');
202
+ const dlSpeed = document.getElementById('dlSpeed');
203
+ const dlSize = document.getElementById('dlSize');
204
+ const dlEta = document.getElementById('dlEta');
205
+ const dlLog = document.getElementById('dlLog');
206
+ const dlFileName = document.getElementById('dlFileName');
207
+ const dlCompleteActions = document.getElementById('dlCompleteActions');
208
+ const finalDownloadLink = document.getElementById('finalDownloadLink');
209
+ const newDownloadBtn = document.getElementById('newDownloadBtn');
210
+
211
+ // --- Utils ---
212
+ const formatTime = (sec) => {
213
+ if (!sec || sec < 0) return '--:--';
214
+ const h = Math.floor(sec / 3600);
215
+ const m = Math.floor((sec % 3600) / 60);
216
+ const s = Math.floor(sec % 60);
217
+ return (h > 0 ? `${h}:` : '') + `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
218
+ };
219
+ const formatBytes = (bytes, decimals = 1) => {
220
+ if (bytes === 0) return '0 Octets';
221
+ const k = 1024;
222
+ const sizes = ['Octets', 'Ko', 'Mo', 'Go', 'To'];
223
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
224
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
225
+ };
226
+ const showView = (step) => {
227
+ Object.values(views).forEach(v => v.classList.add('hidden'));
228
+ views[step].classList.remove('hidden');
229
+ steppers.forEach((s, i) => {
230
+ s.classList.remove('active', 'done');
231
+ if (i + 1 < step) s.classList.add('done');
232
+ else if (i + 1 === step) s.classList.add('active');
233
+ });
234
+ window.scrollTo({ top: 0, behavior: 'smooth' });
235
+ };
236
+ const log = (msg, type = 'info') => {
237
+ 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' };
238
+ const time = new Date().toLocaleTimeString();
239
+ const el = document.createElement('div');
240
+ el.className = `${colors[type]} font-mono text-xs`;
241
+ el.innerHTML = `<span class="text-gray-400">[${time}]</span> ${msg}`;
242
+ dlLog.appendChild(el);
243
+ dlLog.scrollTop = dlLog.scrollHeight;
244
+ };
245
+
246
+ // --- Event Listeners ---
247
+
248
+ // 1. Analyze URL
249
+ analyzeBtn.addEventListener('click', async () => {
250
+ const url = urlInput.value.trim();
251
+ if (!url) return showError("Veuillez entrer une URL.");
252
+ if (!isValidUrl(url)) return showError("URL invalide.");
253
+
254
+ setAnalyzing(true);
255
+ hideError();
256
+
257
+ try {
258
+ const res = await fetch('/api/analyze', {
259
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ url })
261
+ });
262
+ const data = await res.json();
263
+ if (!res.ok) throw new Error(data.detail || "Erreur analyse");
264
+
265
+ // Success
266
+ renderMeta(data);
267
+ renderFormats(data.formats);
268
+ showView(2);
269
+ } catch (e) {
270
+ showError(e.message);
271
+ } finally {
272
+ setAnalyzing(false);
273
+ }
274
+ });
275
+
276
+ function isValidUrl(string) { try { new URL(string); return true; } catch (_) { return false; } }
277
+ function setAnalyzing(b) { analyzeBtn.disabled = b; analyzeSpinner.classList.toggle('hidden', !b); analyzeText.textContent = b ? 'Analyse...' : 'Analyser'; }
278
+ function showError(msg) { urlError.textContent = msg; urlError.classList.remove('hidden'); }
279
+ function hideError() { urlError.classList.add('hidden'); }
280
+
281
+ // 2. Render Meta
282
+ function renderMeta(meta) {
283
+ videoTitle.textContent = meta.title;
284
+ 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)}`;
285
+ 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'}`;
286
+ thumbImg.src = meta.thumbnail;
287
+ thumbImg.alt = meta.title;
288
+ }
289
+
290
+ // 3. Render Formats
291
+ function renderFormats(formats) {
292
+ formatList.innerHTML = '';
293
+ formatCount.textContent = `(${formats.length} disponibles)`;
294
+
295
+ const isAudioOnly = document.querySelector('input[name="dlType"]:checked').value === 'audio';
296
+
297
+ formats.forEach((f, i) => {
298
+ // Filtrer selon type choisi
299
+ const hasVideo = f.vcodec !== 'none';
300
+ const hasAudio = f.acodec !== 'none';
301
+
302
+ if (isAudioOnly && !hasAudio) return; // Pas de pure video pour audio only
303
+ if (!isAudioOnly && !hasVideo) return; // Pas de pure audio pour video mode (sauf si on veut juste audio, mais radio gère ça)
304
+
305
+ const el = document.createElement('label');
306
+ el.className = `format-option flex items-center justify-between p-3 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`;
307
+ el.innerHTML = `
308
+ <input type="radio" name="format" value="${f.format_id}" class="sr-only peer" ${i === 0 ? 'checked' : ''} data-format='${JSON.stringify(f)}'>
309
+ <div class="flex-1 min-w-0 mr-3">
310
+ <div class="font-medium text-gray-800 dark:text-white truncate">${f.resolution}</div>
311
+ <div class="text-xs text-gray-500 dark:text-gray-400">${f.note} • ${f.ext.toUpperCase()}</div>
312
+ </div>
313
+ <div class="text-right text-sm font-mono font-semibold text-gray-700 dark:text-gray-300 whitespace-nowrap">
314
+ ${f.filesize_mb > 0 ? `~${f.filesize_mb} Mo` : `<span class="text-gray-400">Taille ?</span>`}
315
+ </div>
316
+ `;
317
+ formatList.appendChild(el);
318
+ });
319
+
320
+ // Mettre à jour sélection
321
+ updateSelectedFormat();
322
+ }
323
+
324
+ // Gestion changement Type (Video/Audio)
325
+ dlTypeRadios.forEach(r => r.addEventListener('change', () => {
326
+ // Re-render formats avec filtre
327
+ // On a besoin des formats originaux -> les stocker dans variable globale
328
+ if (window.lastFormats) renderFormats(window.lastFormats);
329
+ }));
330
+
331
+ function updateSelectedFormat() {
332
+ const checked = document.querySelector('input[name="format"]:checked');
333
+ if (checked) {
334
+ selectedFormat = JSON.parse(checked.dataset.format);
335
+ startDownloadBtn.disabled = false;
336
+ } else {
337
+ selectedFormat = null;
338
+ startDownloadBtn.disabled = true;
339
+ }
340
+ }
341
+ formatList.addEventListener('change', (e) => {
342
+ if (e.target.name === 'format') updateSelectedFormat();
343
+ });
344
+
345
+ // 4. Back Button
346
+ backBtn.addEventListener('click', () => showView(1));
347
+
348
+ // 5. Start Download -> WebSocket
349
+ startDownloadBtn.addEventListener('click', async () => {
350
+ if (!selectedFormat) return;
351
+
352
+ const url = urlInput.value.trim();
353
+ const audioOnly = document.querySelector('input[name="dlType"]:checked').value === 'audio';
354
+
355
+ showView(3);
356
+ dlCompleteActions.classList.add('hidden');
357
+ dlBar.style.width = '0%';
358
+ dlPercent.textContent = '0%';
359
+ dlLog.innerHTML = '';
360
+ dlFileName.textContent = `Connexion à ${selectedFormat.resolution}...`;
361
+
362
+ connectWebSocket(url, selectedFormat.format_id, audioOnly);
363
+ });
364
+
365
+ // --- WebSocket Logic ---
366
+ function connectWebSocket(url, formatId, audioOnly) {
367
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
368
+ const wsUrl = `${proto}//${window.location.host}/api/download`;
369
+
370
+ ws = new WebSocket(wsUrl);
371
+
372
+ ws.onopen = () => {
373
+ log('Connexion établie. Envoi de la configuration...', 'success');
374
+ ws.send(JSON.stringify({ url, format_id: formatId, audio_only: audioOnly }));
375
+ };
376
+
377
+ ws.onmessage = (event) => {
378
+ const msg = JSON.parse(event.data);
379
+ handleWsMessage(msg);
380
+ };
381
+
382
+ ws.onerror = (err) => log('Erreur WebSocket: ' + err.message, 'error');
383
+ ws.onclose = () => log('Connexion fermée.', 'warn');
384
+ }
385
+
386
+ function handleWsMessage(msg) {
387
+ switch (msg.stage) {
388
+ case 'downloading':
389
+ const pct = parseFloat(msg.percent.replace('%', '')) || 0;
390
+ dlBar.style.width = `${pct}%`;
391
+ dlPercent.textContent = msg.percent;
392
+ dlSpeed.textContent = `${msg.speed_mbps.toFixed(2)} Mo/s`;
393
+ dlSize.textContent = `${msg.downloaded_mb.toFixed(1)} / ${msg.total_mb.toFixed(1)} Mo`;
394
+ dlEta.textContent = `ETA: ${formatTime(msg.eta_sec)}`;
395
+ // Log seulement si changement significatif ou toutes les 5s (throttle)
396
+ if (pct % 10 < 0.5) log(`Téléchargement: ${msg.percent} (${msg.speed_mbps.toFixed(1)} Mo/s)`, 'info');
397
+ break;
398
+
399
+ case 'post_processing':
400
+ dlBar.style.width = '99%';
401
+ dlPercent.textContent = '99%';
402
+ dlFileName.textContent = 'Fusion / Conversion en cours...';
403
+ log('Fusion des flux (FFmpeg)...', 'warn');
404
+ break;
405
+
406
+ case 'complete':
407
+ dlBar.style.width = '100%';
408
+ dlPercent.textContent = '100%';
409
+ dlSpeed.textContent = 'Terminé';
410
+ dlFileName.textContent = msg.filename;
411
+ log('Terminé ! Prêt à être enregistré.', 'success');
412
+
413
+ finalDownloadLink.href = msg.download_url;
414
+ finalDownloadLink.download = msg.filename;
415
+ dlCompleteActions.classList.remove('hidden');
416
+ if(ws) { ws.close(); ws = null; }
417
+ break;
418
+
419
+ case 'error':
420
+ log(`ERREUR: ${msg.info}`, 'error');
421
+ dlFileName.textContent = 'Échec du téléchargement';
422
+ if(ws) { ws.close(); ws = null; }
423
+ break;
424
+ }
425
+ }
426
+
427
+ // 6. New Download
428
+ newDownloadBtn.addEventListener('click', () => {
429
+ // Reset UI
430
+ urlInput.value = '';
431
+ selectedFormat = null;
432
+ showView(1);
433
+ });
434
+
435
+ // Init
436
+ document.addEventListener('DOMContentLoaded', () => urlInput.focus());
437
+
438
+ </script>
439
+ </body>
440
+ </html>