1229 lines
53 KiB
HTML
1229 lines
53 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>A13 TV</title>
|
|
<script src="tailwind.js"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
surface: {
|
|
950: '#07070f',
|
|
900: '#0d0d1a',
|
|
800: '#141428',
|
|
700: '#1c1c35',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
:root {
|
|
--accent: #7c3aed;
|
|
--accent-light: #a78bfa;
|
|
--accent-glow: rgba(124, 58, 237, 0.25);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
background: #07070f;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: #2d2d4e; border-radius: 3px; }
|
|
::-webkit-scrollbar-thumb:hover { background: #3d3d6e; }
|
|
|
|
/* Channel card */
|
|
.channel-card {
|
|
cursor: pointer;
|
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
|
}
|
|
.channel-card:hover {
|
|
transform: translateY(-3px) scale(1.025);
|
|
border-color: rgba(124, 58, 237, 0.45) !important;
|
|
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.18);
|
|
}
|
|
.channel-card:active {
|
|
transform: translateY(-1px) scale(1.01);
|
|
}
|
|
|
|
/* Logo fallback (no text, just icon) */
|
|
.logo-fallback {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Sidebar group items */
|
|
.group-btn {
|
|
transition: background 0.12s ease, color 0.12s ease;
|
|
}
|
|
.group-btn:not(.active):hover {
|
|
background: rgba(255,255,255,0.05);
|
|
}
|
|
.group-btn.active {
|
|
background: rgba(124, 58, 237, 0.18);
|
|
border-left: 2px solid var(--accent);
|
|
color: var(--accent-light);
|
|
}
|
|
|
|
/* Mirror rows */
|
|
.mirror-row {
|
|
transition: background 0.1s ease;
|
|
}
|
|
.mirror-row:hover {
|
|
background: rgba(255,255,255,0.04);
|
|
}
|
|
|
|
/* Modal */
|
|
#modalOverlay {
|
|
animation: fadeOverlay 0.15s ease;
|
|
}
|
|
#modalCard {
|
|
animation: slideUp 0.2s cubic-bezier(0.34, 1.4, 0.64, 1);
|
|
}
|
|
@keyframes fadeOverlay {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translateY(24px) scale(0.96); }
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
|
|
/* Toast */
|
|
#toast {
|
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
}
|
|
|
|
/* Dropdown menus */
|
|
.dropdown-menu {
|
|
animation: dropIn 0.12s ease;
|
|
}
|
|
@keyframes dropIn {
|
|
from { opacity: 0; transform: translateY(-6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
/* Spin loader */
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.spinner { animation: spin 0.75s linear infinite; }
|
|
|
|
/* Search input focus ring */
|
|
#searchInput:focus {
|
|
border-color: rgba(124, 58, 237, 0.6) !important;
|
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="text-gray-100 min-h-screen">
|
|
|
|
<!-- ═══════════════════════════════════════════════════════ TOP BAR ══════ -->
|
|
<header id="topbar"
|
|
class="fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-5 py-3"
|
|
style="background:rgba(7,7,15,0.9);backdrop-filter:blur(16px);
|
|
border-bottom:1px solid rgba(255,255,255,0.07);">
|
|
|
|
<!-- Brand -->
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<svg class="w-6 h-6" style="color:var(--accent-light)" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"
|
|
d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.235a1 1 0 01-1.447.894L15 14
|
|
M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
<span class="font-bold text-base tracking-tight hidden sm:inline">
|
|
A13<span style="color:var(--accent-light)">TV</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Search -->
|
|
<div class="relative flex-1 max-w-sm">
|
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
<input id="searchInput" type="text" placeholder="Buscar canales…"
|
|
class="w-full pl-8 pr-3 py-1.5 rounded-lg text-sm text-gray-200
|
|
placeholder-gray-600 outline-none transition-all"
|
|
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
|
|
</div>
|
|
|
|
<!-- Count badge -->
|
|
<span id="countBadge" class="text-xs text-gray-600 whitespace-nowrap hidden md:block flex-shrink-0"></span>
|
|
|
|
<!-- Tag filter chips (populated dynamically) -->
|
|
<div id="topTagsBar" class="hidden items-center gap-1 flex-shrink-0 overflow-x-auto"></div>
|
|
|
|
<!-- Spacer -->
|
|
<div class="flex-1 hidden md:block"></div>
|
|
|
|
<!-- Source label -->
|
|
<span id="sourceLabel" class="text-xs text-gray-600 hidden lg:block flex-shrink-0 truncate max-w-32"></span>
|
|
|
|
<!-- Country filter dropdown -->
|
|
<div class="relative flex-shrink-0" id="countryWrap">
|
|
<button id="countryBtn" onclick="toggleDropdown('country')"
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
|
text-gray-300 transition-colors hover:text-white"
|
|
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
|
|
<span>🌐</span>
|
|
<span class="hidden sm:inline">Idiomas</span>
|
|
<span id="countryBtnCount"
|
|
class="hidden text-[10px] font-bold px-1.5 py-0.5 rounded-full"
|
|
style="background:rgba(124,58,237,0.4);color:#c4b5fd"></span>
|
|
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="countryMenu"
|
|
class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl overflow-hidden z-50"
|
|
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
|
|
box-shadow:0 16px 48px rgba(0,0,0,0.6);min-width:160px;max-height:320px;overflow-y:auto">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import dropdown -->
|
|
<div class="relative flex-shrink-0" id="importWrap">
|
|
<button id="importBtn" onclick="toggleDropdown('import')"
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
|
text-gray-300 transition-colors hover:text-white"
|
|
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
|
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
|
</svg>
|
|
<span class="hidden sm:inline">Importar</span>
|
|
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="importMenu" class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl
|
|
overflow-hidden z-50 min-w-40"
|
|
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
|
|
box-shadow:0 16px 48px rgba(0,0,0,0.6);">
|
|
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
|
|
hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
onclick="triggerFileInput('m3u')">
|
|
<svg class="w-4 h-4 flex-shrink-0" style="color:var(--accent-light)"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
|
|
a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 8.414V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
Importar M3U
|
|
</button>
|
|
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
|
|
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
|
|
hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
onclick="triggerFileInput('json')">
|
|
<svg class="w-4 h-4 flex-shrink-0 text-amber-400"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
Importar JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export dropdown -->
|
|
<div class="relative flex-shrink-0" id="exportWrap">
|
|
<button id="exportBtn" onclick="toggleDropdown('export')"
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
|
text-gray-300 transition-colors hover:text-white"
|
|
style="background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.1);">
|
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
|
</svg>
|
|
<span class="hidden sm:inline">Exportar</span>
|
|
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="exportMenu" class="dropdown-menu hidden absolute right-0 top-full mt-1.5 rounded-xl
|
|
overflow-hidden z-50 min-w-40"
|
|
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
|
|
box-shadow:0 16px 48px rgba(0,0,0,0.6);">
|
|
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
|
|
hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
onclick="doExport('json')">
|
|
<svg class="w-4 h-4 flex-shrink-0 text-amber-400"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
Exportar JSON
|
|
</button>
|
|
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
|
|
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
|
|
hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
onclick="doExport('m3u')">
|
|
<svg class="w-4 h-4 flex-shrink-0" style="color:var(--accent-light)"
|
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
|
|
a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 8.414V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
Exportar M3U
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden file inputs -->
|
|
<input type="file" id="m3uFileInput" accept=".m3u,.m3u8" class="hidden"
|
|
onchange="handleImport('m3u',this)">
|
|
<input type="file" id="jsonFileInput" accept=".json" class="hidden"
|
|
onchange="handleImport('json',this)">
|
|
</header>
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════ MAIN LAYOUT ══════════ -->
|
|
<div class="flex min-h-screen" style="padding-top:52px">
|
|
|
|
<!-- Sidebar -->
|
|
<aside class="fixed left-0 bottom-0 w-52 overflow-y-auto z-20 hidden md:flex flex-col"
|
|
style="top:52px;background:rgba(7,7,15,0.97);border-right:1px solid rgba(255,255,255,0.06);">
|
|
<div class="p-3 pt-4 flex-1">
|
|
<nav id="groupNav"></nav>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Content -->
|
|
<main class="flex-1 md:ml-52 p-5">
|
|
|
|
<!-- Loading -->
|
|
<div id="stateLoading" class="flex flex-col items-center justify-center gap-4 py-32">
|
|
<div class="w-9 h-9 rounded-full border-2 border-t-transparent spinner"
|
|
style="border-color:var(--accent);border-top-color:transparent"></div>
|
|
<p class="text-sm text-gray-600">Cargando canales…</p>
|
|
</div>
|
|
|
|
<!-- Empty -->
|
|
<div id="stateEmpty" class="hidden flex-col items-center justify-center gap-3 py-32">
|
|
<svg class="w-14 h-14 text-gray-800" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
|
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01
|
|
M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<p class="text-gray-600 text-sm">No se encontraron canales</p>
|
|
</div>
|
|
|
|
<!-- Grid -->
|
|
<div id="channelGrid"
|
|
class="hidden grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════ MODAL ════════ -->
|
|
<div id="modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4">
|
|
<div id="modalOverlay" class="absolute inset-0"
|
|
style="background:rgba(0,0,0,0.78);backdrop-filter:blur(6px)"
|
|
onclick="closeModal()"></div>
|
|
|
|
<div id="modalCard" class="relative w-full max-w-2xl rounded-2xl overflow-hidden"
|
|
style="background:#10101f;border:1px solid rgba(255,255,255,0.1);
|
|
box-shadow:0 32px 80px rgba(0,0,0,0.85);">
|
|
|
|
<!-- Modal header -->
|
|
<div class="flex items-start gap-4 p-6 pb-5"
|
|
style="border-bottom:1px solid rgba(255,255,255,0.07)">
|
|
|
|
<div id="modalLogoWrap"
|
|
class="w-20 h-20 rounded-xl overflow-hidden flex-shrink-0 logo-placeholder text-xl"
|
|
style="font-size:1.25rem"></div>
|
|
|
|
<div class="flex-1 min-w-0 pt-0.5">
|
|
<h2 id="modalTitle" class="text-xl font-bold text-white leading-tight"></h2>
|
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
|
<span id="modalFlag" class="flex items-center"></span>
|
|
<span id="modalGroupBadge"
|
|
class="text-xs px-2.5 py-1 rounded-full font-medium"
|
|
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
|
|
border:1px solid rgba(124,58,237,0.3)"></span>
|
|
<span id="modalSubcatBadge"
|
|
class="hidden text-xs px-2.5 py-1 rounded-full font-medium"
|
|
style="background:rgba(251,191,36,0.12);color:#fbbf24;
|
|
border:1px solid rgba(251,191,36,0.25)"></span>
|
|
</div>
|
|
<div id="modalTags" class="hidden flex gap-2 flex-wrap mt-1.5"></div>
|
|
<p id="modalMirrorCount" class="text-xs text-gray-600 mt-2"></p>
|
|
</div>
|
|
|
|
<button onclick="closeModal()"
|
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg
|
|
text-gray-600 hover:text-white hover:bg-white/8 transition-colors">
|
|
<svg class="w-4.5 h-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mirror list header -->
|
|
<div class="flex items-center gap-4 px-6 py-2 text-[10px] uppercase tracking-widest text-gray-700 font-semibold"
|
|
style="border-bottom:1px solid rgba(255,255,255,0.04)">
|
|
<span class="w-5">#</span>
|
|
<span class="w-32">Calidad</span>
|
|
<span class="flex-1 hidden sm:block">Hash</span>
|
|
<span class="w-20 text-right">Acción</span>
|
|
</div>
|
|
|
|
<!-- Mirrors -->
|
|
<div id="modalMirrors" class="py-2 max-h-72 overflow-y-auto"></div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status popover (fixed, shared across all mirror rows) -->
|
|
<div id="statusPopover"
|
|
class="hidden fixed z-[300] rounded-xl overflow-hidden"
|
|
style="background:#13132a;border:1px solid rgba(255,255,255,0.1);
|
|
box-shadow:0 16px 48px rgba(0,0,0,0.6);min-width:160px">
|
|
</div>
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════ TOAST ════════ -->
|
|
<div id="toast"
|
|
class="fixed bottom-6 right-6 z-50 opacity-0 translate-y-2 pointer-events-none
|
|
flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-sm font-medium"
|
|
style="background:#14142a;border:1px solid rgba(255,255,255,0.12);
|
|
box-shadow:0 8px 32px rgba(0,0,0,0.5);">
|
|
<span id="toastIcon"></span>
|
|
<span id="toastMsg"></span>
|
|
</div>
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════ JAVASCRIPT ════════ -->
|
|
<script>
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
const $ = id => document.getElementById(id);
|
|
|
|
function escHTML(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
function escAttr(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"');
|
|
}
|
|
// ── App state ────────────────────────────────────────────────────────────────
|
|
const App = {
|
|
channels: [],
|
|
groups: [],
|
|
groupMeta: {},
|
|
activeGroup: null,
|
|
activeSubcategory: null,
|
|
activeTags: new Set(),
|
|
activeCountries: new Set(),
|
|
expandedGroups: new Set(),
|
|
search: '',
|
|
|
|
filtered() {
|
|
let ch = this.channels;
|
|
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
|
|
if (this.activeSubcategory) ch = ch.filter(c => c.subcategory === this.activeSubcategory);
|
|
if (this.activeTags.size) ch = ch.filter(c => c.tags && c.tags.some(t => this.activeTags.has(t)));
|
|
if (this.activeCountries.size) ch = ch.filter(c => this.activeCountries.has(c.country_code));
|
|
const q = this.search.toLowerCase().trim();
|
|
if (q) ch = ch.filter(c =>
|
|
c.name.toLowerCase().includes(q) ||
|
|
c.group.toLowerCase().includes(q)
|
|
);
|
|
return ch;
|
|
}
|
|
};
|
|
|
|
// ── Quality tier system ──────────────────────────────────────────────────────
|
|
const TIER_CONFIG = {
|
|
bronze: { style: 'background:rgba(100,55,10,0.5);color:#cd9a5a;border:1px solid rgba(180,110,40,0.5)' },
|
|
silver: { style: 'background:rgba(80,96,110,0.4);color:#e2e8f0;border:1px solid rgba(148,163,184,0.45)' },
|
|
gold: { style: 'background:rgba(120,88,5,0.45);color:#fcd34d;border:1px solid rgba(234,179,8,0.5)' },
|
|
diamond: { style: 'background:rgba(6,110,140,0.35);color:#a5f3fc;border:1px solid rgba(34,211,238,0.45)' },
|
|
};
|
|
|
|
const RES_TO_TIER = {
|
|
'240p': 'bronze', '360p': 'bronze',
|
|
'480p': 'silver', '720p': 'silver',
|
|
'1080p': 'gold', '1440p': 'gold',
|
|
'2160p': 'diamond', '4K': 'diamond',
|
|
};
|
|
|
|
function getQualityTier(res) { return RES_TO_TIER[res] || null; }
|
|
|
|
function tierBadge(res) {
|
|
const tier = getQualityTier(res);
|
|
if (!tier) return '';
|
|
const { style } = TIER_CONFIG[tier];
|
|
return `<span class="text-[10px] px-2 py-0.5 rounded font-mono font-semibold tracking-wide" style="${style}">${escHTML(res)}</span>`;
|
|
}
|
|
|
|
function bestMirrorTier(mirrors) {
|
|
const order = ['diamond', 'gold', 'silver', 'bronze'];
|
|
for (const t of order) {
|
|
if (mirrors.some(m => getQualityTier(m.resolution) === t)) return t;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Mirror status ────────────────────────────────────────────────────────────
|
|
const STATUS_CONFIG = {
|
|
unknown: { emoji: '❔', label: 'Por comprobar', bg: 'transparent' },
|
|
ok: { emoji: '✅', label: 'Funciona', bg: 'rgba(34,197,94,0.08)' },
|
|
issues: { emoji: '⚠️', label: 'Problemas', bg: 'rgba(245,158,11,0.10)' },
|
|
broken: { emoji: '⛔️', label: 'No funciona', bg: 'rgba(239,68,68,0.10)' },
|
|
};
|
|
const STATUS_CYCLE = ['unknown', 'ok', 'issues', 'broken'];
|
|
|
|
function getMirrorStatus(hash) {
|
|
return localStorage.getItem(`ms_${hash}`) || 'unknown';
|
|
}
|
|
function setMirrorStatus(hash, status) {
|
|
localStorage.setItem(`ms_${hash}`, status);
|
|
}
|
|
|
|
// ── Flag helper ──────────────────────────────────────────────────────────────
|
|
const TV_ICON = `<svg class="w-7 h-7 opacity-30" fill="none" viewBox="0 0 24 24" stroke="white" stroke-width="1.2">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.235a1 1 0 01-1.447.894L15 14
|
|
M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>`;
|
|
|
|
function flagHTML(code) {
|
|
if (!code) return '';
|
|
return `<img src="flags/${escAttr(code)}.svg" alt="${escAttr(code)}"
|
|
class="h-3.5 w-auto rounded-sm opacity-90"
|
|
onerror="this.style.display='none'" loading="lazy">`;
|
|
}
|
|
|
|
// ── Logo HTML helpers ────────────────────────────────────────────────────────
|
|
function logoImgHTML(url, cls) {
|
|
const safeSrc = escAttr(url);
|
|
return `<img src="${safeSrc}" alt="" class="${cls}"
|
|
style="object-fit:contain;padding:6px"
|
|
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
|
|
loading="lazy">
|
|
<div class="${cls} logo-fallback rounded-xl" style="display:none">${TV_ICON}</div>`;
|
|
}
|
|
|
|
function logoFallback(cls) {
|
|
return `<div class="${cls} logo-fallback rounded-xl">${TV_ICON}</div>`;
|
|
}
|
|
|
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
function renderAll() {
|
|
renderSidebar();
|
|
renderGrid();
|
|
renderTopTags();
|
|
renderTopCountries();
|
|
}
|
|
|
|
const CHEVRON_DOWN = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>`;
|
|
const CHEVRON_RIGHT = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>`;
|
|
|
|
function renderTopCountries() {
|
|
const codes = [...new Set(App.channels.map(c => c.country_code).filter(Boolean))].sort();
|
|
const wrap = $('countryWrap');
|
|
if (!wrap) return;
|
|
if (codes.length === 0) { wrap.classList.add('hidden'); return; }
|
|
wrap.classList.remove('hidden');
|
|
|
|
const count = App.activeCountries.size;
|
|
const badge = $('countryBtnCount');
|
|
if (count > 0) { badge.textContent = count; badge.classList.remove('hidden'); }
|
|
else { badge.classList.add('hidden'); }
|
|
|
|
const allActive = count === 0;
|
|
$('countryMenu').innerHTML =
|
|
`<button class="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm
|
|
text-gray-300 hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
style="${allActive ? 'background:rgba(124,58,237,0.15)' : ''}"
|
|
data-country-all>
|
|
<span class="w-5 text-center">🌐</span>
|
|
<span class="flex-1">Todos</span>
|
|
${allActive ? '<span class="text-purple-400 text-xs">✓</span>' : ''}
|
|
</button>
|
|
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>`
|
|
+ codes.map(code => {
|
|
const isActive = App.activeCountries.has(code);
|
|
return `<button class="flex items-center gap-2.5 w-full px-4 py-2 text-sm
|
|
text-gray-300 hover:text-white hover:bg-white/5 transition-colors"
|
|
style="${isActive ? 'background:rgba(124,58,237,0.1)' : ''}"
|
|
data-country="${escAttr(code)}">
|
|
<img src="flags/${escAttr(code)}.svg"
|
|
class="w-5 h-3.5 rounded-sm object-cover flex-shrink-0"
|
|
onerror="this.style.display='none'">
|
|
<span class="flex-1 text-left font-mono text-xs uppercase">${escHTML(code)}</span>
|
|
${isActive ? '<span class="text-purple-400 text-xs">✓</span>' : ''}
|
|
</button>`;
|
|
}).join('')
|
|
+ (count > 0
|
|
? `<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
|
|
<button class="flex items-center gap-2 w-full px-4 py-2.5 text-sm
|
|
text-red-400 hover:text-red-300 hover:bg-white/5 transition-colors"
|
|
data-country-clear>
|
|
<span>✕</span><span class="ml-1">Borrar selección</span>
|
|
</button>`
|
|
: '');
|
|
}
|
|
|
|
function renderTopTags() {
|
|
const bar = $('topTagsBar');
|
|
const allTags = [...new Set(App.channels.flatMap(c => c.tags || []))].sort();
|
|
if (allTags.length === 0) {
|
|
bar.classList.add('hidden');
|
|
bar.classList.remove('flex');
|
|
return;
|
|
}
|
|
bar.classList.remove('hidden');
|
|
bar.classList.add('flex');
|
|
bar.innerHTML = allTags.map(tag => {
|
|
const isActive = App.activeTags.has(tag);
|
|
return `<button class="flex-shrink-0 px-2 py-1 rounded-lg text-sm leading-none transition-all"
|
|
style="${isActive
|
|
? 'background:rgba(124,58,237,0.3);border:1px solid rgba(124,58,237,0.6)'
|
|
: 'background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);opacity:0.6'}"
|
|
data-top-tag="${escAttr(tag)}">${escHTML(tag)}</button>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderSidebar() {
|
|
const total = App.channels.length;
|
|
const isAll = App.activeGroup === null && App.activeSubcategory === null && App.activeTags.size === 0;
|
|
|
|
// "Todos" button
|
|
let html = `<button class="group-btn flex items-center justify-between w-full px-3 py-2
|
|
rounded-lg text-sm mb-2 ${isAll ? 'active' : ''}"
|
|
data-action="all">
|
|
<span class="font-medium ${isAll ? '' : 'text-gray-500'}">Todos</span>
|
|
<span class="text-xs font-mono ${isAll ? '' : 'text-gray-700'}">${total}</span>
|
|
</button>`;
|
|
|
|
// Groups section
|
|
html += `<p class="text-[10px] uppercase tracking-widest font-semibold text-gray-600 px-2 mb-1">Grupos</p>`;
|
|
|
|
for (const g of App.groups) {
|
|
const count = App.channels.filter(c => c.group === g).length;
|
|
const isGroupActive = App.activeGroup === g;
|
|
const subcats = [...new Set(
|
|
App.channels.filter(c => c.group === g && c.subcategory).map(c => c.subcategory)
|
|
)].sort();
|
|
const hasSubcats = subcats.length > 0;
|
|
const isExpanded = App.expandedGroups.has(g) || isGroupActive;
|
|
const safeG = escHTML(g);
|
|
|
|
html += `<div class="flex items-center mb-0.5">
|
|
<button class="group-btn flex items-center justify-between flex-1 min-w-0 px-3 py-2
|
|
rounded-lg text-sm ${isGroupActive && !App.activeSubcategory ? 'active' : ''}"
|
|
data-group="${escAttr(g)}">
|
|
<span class="font-medium truncate ${isGroupActive ? '' : 'text-gray-500'}">${safeG}</span>
|
|
<span class="text-xs font-mono ml-2 flex-shrink-0 ${isGroupActive ? '' : 'text-gray-700'}">${count}</span>
|
|
</button>
|
|
${hasSubcats
|
|
? `<button class="flex-shrink-0 w-6 h-8 flex items-center justify-center ml-0.5
|
|
rounded text-gray-600 hover:text-gray-300 transition-colors"
|
|
data-expand-group="${escAttr(g)}">
|
|
${isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT}
|
|
</button>`
|
|
: ''}
|
|
</div>`;
|
|
|
|
// Subcategories — shown when group is expanded or active
|
|
if (hasSubcats && isExpanded) {
|
|
for (const sub of subcats) {
|
|
const subCount = App.channels.filter(c => c.group === g && c.subcategory === sub).length;
|
|
const isSubActive = App.activeSubcategory === sub;
|
|
html += `<button class="group-btn flex items-center justify-between w-full pl-7 pr-3 py-1.5
|
|
rounded-lg text-xs mb-0.5 ${isSubActive ? 'active' : ''}"
|
|
data-group="${escAttr(g)}" data-subcat="${escAttr(sub)}">
|
|
<span class="truncate ${isSubActive ? '' : 'text-gray-500'}">· ${escHTML(sub)}</span>
|
|
<span class="font-mono ml-2 flex-shrink-0 ${isSubActive ? '' : 'text-gray-700'}">${subCount}</span>
|
|
</button>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
$('groupNav').innerHTML = html;
|
|
}
|
|
|
|
function renderGrid() {
|
|
const channels = App.filtered();
|
|
const grid = $('channelGrid');
|
|
const empty = $('stateEmpty');
|
|
const loading = $('stateLoading');
|
|
const badge = $('countBadge');
|
|
|
|
loading.classList.add('hidden');
|
|
|
|
if (channels.length === 0) {
|
|
grid.classList.add('hidden');
|
|
empty.classList.remove('hidden');
|
|
empty.classList.add('flex');
|
|
badge.textContent = '';
|
|
} else {
|
|
empty.classList.add('hidden');
|
|
empty.classList.remove('flex');
|
|
grid.classList.remove('hidden');
|
|
const total = App.channels.length;
|
|
badge.textContent = channels.length < total
|
|
? `${channels.length} / ${total} canales`
|
|
: `${total} canal${total !== 1 ? 'es' : ''}`;
|
|
|
|
grid.innerHTML = channels.map(channelCardHTML).join('');
|
|
}
|
|
}
|
|
|
|
function channelCardHTML(ch) {
|
|
const safeId = escAttr(ch.id);
|
|
const safeName = escHTML(ch.name);
|
|
const safeGrp = escHTML(ch.group);
|
|
|
|
const logoSection = ch.logo_url
|
|
? logoImgHTML(ch.logo_url, 'w-24 h-24 rounded-xl')
|
|
: logoFallback('w-24 h-24');
|
|
|
|
const best = bestMirrorTier(ch.mirrors);
|
|
const flagEl = flagHTML(ch.country_code || '');
|
|
const tagsEl = (ch.tags && ch.tags.length > 0)
|
|
? ch.tags.map(t => `<span class="text-sm leading-none" title="${escAttr(t)}">${escHTML(t)}</span>`).join('')
|
|
: '';
|
|
const qualityEl = best ? tierBadge(ch.mirrors.find(m => getQualityTier(m.resolution) === best)?.resolution) : '';
|
|
const infoRow = (flagEl || tagsEl || qualityEl)
|
|
? `<div class="flex items-center justify-between w-full px-1">
|
|
<div class="flex items-center gap-1">
|
|
${flagEl}
|
|
<div class="flex gap-0.5 flex-wrap">${tagsEl}</div>
|
|
</div>
|
|
<div class="flex-shrink-0">${qualityEl}</div>
|
|
</div>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="channel-card rounded-xl p-4 flex flex-col items-center gap-2 select-none"
|
|
style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)"
|
|
data-channel-id="${safeId}">
|
|
<div class="flex items-center justify-center">${logoSection}</div>
|
|
<div class="w-full text-center min-w-0">
|
|
<p class="text-sm font-semibold text-white leading-tight truncate" title="${escAttr(ch.name)}">${safeName}</p>
|
|
<p class="text-xs mt-0.5 truncate font-medium" style="color:var(--accent-light);opacity:0.75">${safeGrp}</p>
|
|
</div>
|
|
${infoRow}
|
|
</div>`;
|
|
}
|
|
|
|
// ── Modal ────────────────────────────────────────────────────────────────────
|
|
function openModal(channelId) {
|
|
const ch = App.channels.find(c => c.id === channelId);
|
|
if (!ch) return;
|
|
|
|
$('modalTitle').textContent = ch.name;
|
|
$('modalGroupBadge').textContent = ch.group;
|
|
|
|
const subEl = $('modalSubcatBadge');
|
|
if (ch.subcategory) {
|
|
subEl.textContent = ch.subcategory;
|
|
subEl.classList.remove('hidden');
|
|
} else {
|
|
subEl.classList.add('hidden');
|
|
}
|
|
|
|
// Tags
|
|
const tagsEl = $('modalTags');
|
|
if (ch.tags && ch.tags.length) {
|
|
tagsEl.innerHTML = ch.tags.map(t => `<span class="text-base">${escHTML(t)}</span>`).join('');
|
|
tagsEl.classList.remove('hidden');
|
|
} else {
|
|
tagsEl.classList.add('hidden');
|
|
}
|
|
|
|
const n = ch.mirrors.length;
|
|
$('modalMirrorCount').textContent = `${n} ${n === 1 ? 'mirror disponible' : 'mirrors disponibles'}`;
|
|
|
|
// Logo
|
|
const logoWrap = $('modalLogoWrap');
|
|
logoWrap.className = 'w-20 h-20 rounded-xl overflow-hidden flex-shrink-0';
|
|
if (ch.logo_url) {
|
|
logoWrap.innerHTML = logoImgHTML(ch.logo_url, 'w-full h-full');
|
|
} else {
|
|
logoWrap.innerHTML = `<div class="w-full h-full logo-fallback rounded-xl">${TV_ICON}</div>`;
|
|
}
|
|
|
|
// Flag in modal header (next to group badge)
|
|
const flagEl = $('modalFlag');
|
|
if (flagEl) flagEl.innerHTML = flagHTML(ch.country_code || '');
|
|
|
|
// Sort mirrors best → worst quality
|
|
const TIER_ORDER = ['diamond', 'gold', 'silver', 'bronze'];
|
|
const sorted = [...ch.mirrors].sort((a, b) => {
|
|
const ia = TIER_ORDER.indexOf(getQualityTier(a.resolution));
|
|
const ib = TIER_ORDER.indexOf(getQualityTier(b.resolution));
|
|
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
});
|
|
|
|
// Mirrors
|
|
$('modalMirrors').innerHTML = sorted.map((m, i) => {
|
|
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
|
|
const hashFull = escHTML(m.acestream_hash);
|
|
const hashShort = m.acestream_hash.slice(0, 16) + '…';
|
|
const status = m.status || getMirrorStatus(m.acestream_hash);
|
|
const { emoji, label, bg } = STATUS_CONFIG[status] || STATUS_CONFIG.unknown;
|
|
const safeHash = escAttr(m.acestream_hash);
|
|
const safeChId = escAttr(ch.id);
|
|
|
|
return `
|
|
<div class="mirror-row flex items-center gap-3 px-4 py-2.5 rounded-lg mx-2 mb-0.5"
|
|
style="background:${bg}">
|
|
<span class="text-xs text-gray-700 font-mono w-5 flex-shrink-0 text-right">${i + 1}</span>
|
|
<div class="flex-shrink-0 w-20">
|
|
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
|
|
</div>
|
|
<code class="flex-1 text-xs font-mono text-gray-600 truncate hidden sm:block"
|
|
title="${hashFull}">${hashShort}</code>
|
|
<button class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg
|
|
text-base transition-all cursor-pointer"
|
|
title="${escAttr(label)}"
|
|
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)"
|
|
data-status-hash="${safeHash}"
|
|
data-channel-id="${safeChId}"
|
|
data-current-status="${escAttr(status)}">${emoji}</button>
|
|
<button class="flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg
|
|
text-xs font-semibold transition-all w-20 justify-center"
|
|
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
|
|
border:1px solid rgba(124,58,237,0.3)"
|
|
onmouseover="this.style.background='rgba(124,58,237,0.4)'"
|
|
onmouseout="this.style.background='rgba(124,58,237,0.2)'"
|
|
data-url="${safeUrl}">
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Abrir
|
|
</button>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
$('modal').classList.remove('hidden');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeModal() {
|
|
$('modal').classList.add('hidden');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ── Status popover ───────────────────────────────────────────────────────────
|
|
|
|
let _activeStatusBtn = null;
|
|
|
|
function openStatusPopover(btn) {
|
|
_activeStatusBtn = btn;
|
|
const cur = btn.dataset.currentStatus || 'unknown';
|
|
$('statusPopover').innerHTML = STATUS_CYCLE.map(s => {
|
|
const { emoji, label } = STATUS_CONFIG[s];
|
|
const isActive = s === cur;
|
|
return `<button class="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm
|
|
text-gray-300 hover:text-white hover:bg-white/5 text-left transition-colors"
|
|
style="${isActive ? 'background:rgba(124,58,237,0.15)' : ''}"
|
|
data-set-status="${escAttr(s)}">
|
|
<span class="w-5 text-center">${emoji}</span>
|
|
<span>${escHTML(label)}</span>
|
|
${isActive ? '<span class="ml-auto text-purple-400 text-xs">✓</span>' : ''}
|
|
</button>`;
|
|
}).join('');
|
|
const pop = $('statusPopover');
|
|
pop.classList.remove('hidden');
|
|
const rect = btn.getBoundingClientRect();
|
|
const popH = pop.offsetHeight || 160;
|
|
const top = (rect.bottom + 4 + popH > window.innerHeight)
|
|
? rect.top - popH - 4
|
|
: rect.bottom + 4;
|
|
pop.style.top = `${top}px`;
|
|
pop.style.left = `${rect.left}px`;
|
|
}
|
|
|
|
$('statusPopover').addEventListener('click', e => {
|
|
const opt = e.target.closest('[data-set-status]');
|
|
if (!opt || !_activeStatusBtn) return;
|
|
const next = opt.dataset.setStatus;
|
|
const btn = _activeStatusBtn;
|
|
const hash = btn.dataset.statusHash;
|
|
setMirrorStatus(hash, next);
|
|
btn.textContent = STATUS_CONFIG[next].emoji;
|
|
btn.title = STATUS_CONFIG[next].label;
|
|
btn.dataset.currentStatus = next;
|
|
btn.closest('.mirror-row').style.background = STATUS_CONFIG[next].bg;
|
|
const ch = App.channels.find(c => c.id === btn.dataset.channelId);
|
|
if (ch) { const m = ch.mirrors.find(m => m.acestream_hash === hash); if (m) m.status = next; }
|
|
fetch('/api/mirror/status', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ channel_id: btn.dataset.channelId, acestream_hash: hash, status: next }),
|
|
}).catch(() => {});
|
|
$('statusPopover').classList.add('hidden');
|
|
_activeStatusBtn = null;
|
|
});
|
|
|
|
// ── Event delegation ─────────────────────────────────────────────────────────
|
|
|
|
// Channel grid click → open modal
|
|
$('channelGrid').addEventListener('click', e => {
|
|
const card = e.target.closest('[data-channel-id]');
|
|
if (card) openModal(card.dataset.channelId);
|
|
});
|
|
|
|
// Group nav click → filter / expand
|
|
$('groupNav').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-action], [data-expand-group], [data-group]');
|
|
if (!btn) return;
|
|
|
|
if (btn.dataset.action === 'all') {
|
|
App.activeGroup = null;
|
|
App.activeSubcategory = null;
|
|
App.activeTags.clear();
|
|
App.activeCountries.clear();
|
|
} else if (btn.dataset.expandGroup !== undefined) {
|
|
const g = btn.dataset.expandGroup;
|
|
if (App.expandedGroups.has(g)) App.expandedGroups.delete(g);
|
|
else App.expandedGroups.add(g);
|
|
renderSidebar();
|
|
return;
|
|
} else {
|
|
const g = btn.dataset.group;
|
|
const sub = btn.dataset.subcat;
|
|
if (App.activeGroup !== g) App.activeSubcategory = null;
|
|
App.activeGroup = g;
|
|
App.activeSubcategory = sub || null;
|
|
App.expandedGroups.add(g);
|
|
}
|
|
renderAll();
|
|
});
|
|
|
|
// Country dropdown menu
|
|
$('countryMenu').addEventListener('click', e => {
|
|
if (e.target.closest('[data-country-all]')) {
|
|
App.activeCountries.clear();
|
|
$('countryMenu').classList.add('hidden');
|
|
renderAll();
|
|
return;
|
|
}
|
|
if (e.target.closest('[data-country-clear]')) {
|
|
App.activeCountries.clear();
|
|
renderAll();
|
|
return;
|
|
}
|
|
const btn = e.target.closest('[data-country]');
|
|
if (!btn) return;
|
|
const code = btn.dataset.country;
|
|
if (App.activeCountries.has(code)) App.activeCountries.delete(code);
|
|
else App.activeCountries.add(code);
|
|
renderAll();
|
|
});
|
|
|
|
// Top bar tag chips
|
|
$('topTagsBar').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-top-tag]');
|
|
if (!btn) return;
|
|
const tag = btn.dataset.topTag;
|
|
if (App.activeTags.has(tag)) App.activeTags.delete(tag);
|
|
else App.activeTags.add(tag);
|
|
renderAll();
|
|
});
|
|
|
|
// Mirror open button + status selector
|
|
$('modalMirrors').addEventListener('click', e => {
|
|
const statusBtn = e.target.closest('[data-status-hash]');
|
|
if (statusBtn) {
|
|
openStatusPopover(statusBtn);
|
|
return;
|
|
}
|
|
const btn = e.target.closest('[data-url]');
|
|
if (btn) window.location.href = btn.dataset.url;
|
|
});
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
|
|
// ── Search ───────────────────────────────────────────────────────────────────
|
|
let _searchTimer;
|
|
$('searchInput').addEventListener('input', e => {
|
|
clearTimeout(_searchTimer);
|
|
_searchTimer = setTimeout(() => {
|
|
App.search = e.target.value;
|
|
renderGrid();
|
|
}, 150);
|
|
});
|
|
|
|
// ── Dropdown menus ───────────────────────────────────────────────────────────
|
|
function toggleDropdown(which) {
|
|
const menus = { import: $('importMenu'), export: $('exportMenu'), country: $('countryMenu') };
|
|
Object.keys(menus).forEach(k => { if (k !== which) menus[k].classList.add('hidden'); });
|
|
const menu = menus[which];
|
|
if (menu.classList.contains('hidden')) menu.classList.remove('hidden');
|
|
else menu.classList.add('hidden');
|
|
}
|
|
|
|
document.addEventListener('click', e => {
|
|
if (!$('importWrap').contains(e.target)) $('importMenu').classList.add('hidden');
|
|
if (!$('exportWrap').contains(e.target)) $('exportMenu').classList.add('hidden');
|
|
if (!$('countryWrap').contains(e.target)) $('countryMenu').classList.add('hidden');
|
|
if (_activeStatusBtn && !$('statusPopover').contains(e.target) && e.target !== _activeStatusBtn) {
|
|
$('statusPopover').classList.add('hidden');
|
|
_activeStatusBtn = null;
|
|
}
|
|
});
|
|
|
|
// ── Import/Export helpers ────────────────────────────────────────────────────
|
|
|
|
function slugify(text) {
|
|
return text.toLowerCase().normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function parseImportM3U(text, filename) {
|
|
const EXTINF_ATTR = /([\w-]+)="([^"]*)"/g;
|
|
const ACESTREAM_RE = /^acestream:\/\/([a-f0-9]{40})/i;
|
|
const RESOLUTION_RE = /\s+(480p|720p|1080p|1440p|2160p|4[Kk]|UHD|FHD)\s*$/i;
|
|
const QUALITY_RE = /\s*(\*+)\s*$/;
|
|
const EXTGRP_RE = /group-title="([^"]*)".*?group-logo="([^"]*)"/;
|
|
|
|
function parseChannelName(rawName) {
|
|
let name = rawName.trim();
|
|
const qm = QUALITY_RE.exec(name);
|
|
if (qm) name = name.slice(0, qm.index);
|
|
const rm = RESOLUTION_RE.exec(name);
|
|
let resolution = '';
|
|
if (rm) {
|
|
const r = rm[1].toUpperCase();
|
|
resolution = r === 'FHD' ? '1080p'
|
|
: (r === 'UHD' || r === '2160P') ? '4K'
|
|
: r.startsWith('4K') ? '4K'
|
|
: rm[1];
|
|
name = name.slice(0, rm.index);
|
|
}
|
|
return { base_name: name.trim(), resolution };
|
|
}
|
|
|
|
const rawEntries = [];
|
|
const groupMeta = {};
|
|
let pending = null;
|
|
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
|
|
if (line.startsWith('#EXTGRP:')) {
|
|
const m = EXTGRP_RE.exec(line);
|
|
if (m) groupMeta[m[1]] = m[2];
|
|
continue;
|
|
}
|
|
if (line.startsWith('#EXTINF:')) {
|
|
const attrs = {};
|
|
let m;
|
|
EXTINF_ATTR.lastIndex = 0;
|
|
while ((m = EXTINF_ATTR.exec(line)) !== null) attrs[m[1]] = m[2];
|
|
const ci = line.indexOf(',');
|
|
pending = {
|
|
tvg_id: attrs['tvg-id'] || '',
|
|
tvg_logo: attrs['tvg-logo'] || '',
|
|
group_title: attrs['group-title'] || '',
|
|
raw_name: ci !== -1 ? line.slice(ci + 1).trim() : '',
|
|
};
|
|
continue;
|
|
}
|
|
if (line.startsWith('#')) continue;
|
|
|
|
const am = pending && ACESTREAM_RE.exec(line);
|
|
if (am) {
|
|
rawEntries.push({ ...pending, acestream_hash: am[1] });
|
|
}
|
|
pending = null;
|
|
}
|
|
|
|
const channelMap = new Map();
|
|
const order = [];
|
|
const idSeen = {};
|
|
|
|
for (const entry of rawEntries) {
|
|
const { base_name, resolution } = parseChannelName(entry.raw_name);
|
|
const group = entry.group_title;
|
|
const key = `${base_name.toLowerCase()}\x00${group.toLowerCase()}`;
|
|
const mirror = { resolution, acestream_hash: entry.acestream_hash, status: 'unknown' };
|
|
|
|
if (!channelMap.has(key)) {
|
|
let baseId = slugify(base_name) || slugify(group);
|
|
let channelId;
|
|
if (baseId in idSeen) { idSeen[baseId]++; channelId = `${baseId}-${idSeen[baseId]}`; }
|
|
else { idSeen[baseId] = 0; channelId = baseId; }
|
|
channelMap.set(key, {
|
|
id: channelId, name: base_name, group, subcategory: '',
|
|
country_code: '', logo_url: entry.tvg_logo || '', tags: [],
|
|
_tvg_logo: entry.tvg_logo, mirrors: [],
|
|
});
|
|
order.push(key);
|
|
} else {
|
|
const ch = channelMap.get(key);
|
|
if (!ch._tvg_logo && entry.tvg_logo) { ch._tvg_logo = entry.tvg_logo; ch.logo_url = entry.tvg_logo; }
|
|
}
|
|
channelMap.get(key).mirrors.push(mirror);
|
|
}
|
|
|
|
const channels = order.map(k => { const ch = channelMap.get(k); delete ch._tvg_logo; return ch; });
|
|
const groups = [...new Set(channels.map(c => c.group))].sort();
|
|
return { channels, groups, groupMeta, source: filename };
|
|
}
|
|
|
|
function parseImportJSON(text, filename) {
|
|
const raw = JSON.parse(text);
|
|
const channels = (raw.channels || []).map(ch => {
|
|
if ('base_name' in ch && !('name' in ch)) { ch.name = ch.base_name; delete ch.base_name; }
|
|
ch.tags = ch.tags || [];
|
|
ch.country_code = ch.country_code || '';
|
|
(ch.mirrors || []).forEach(m => {
|
|
delete m.acestream_url; delete m.raw_name; delete m.quality_marker;
|
|
m.status = m.status || 'unknown';
|
|
});
|
|
return ch;
|
|
});
|
|
const rawGroups = raw.groups || [];
|
|
const groups = rawGroups.map(g => typeof g === 'string' ? g : g.name).sort();
|
|
const groupMeta = Object.fromEntries(
|
|
rawGroups.filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
|
|
);
|
|
return { channels, groups, groupMeta, source: raw.source || filename };
|
|
}
|
|
|
|
function buildExportJSON() {
|
|
return JSON.stringify({
|
|
version: '1.0',
|
|
exported_at: new Date().toISOString(),
|
|
source: $('sourceLabel').textContent || 'channels.json',
|
|
channels: App.channels,
|
|
groups: App.groups.map(g => ({ name: g, logo: App.groupMeta[g] || '', subcategories: [] })),
|
|
}, null, 2);
|
|
}
|
|
|
|
function buildExportM3U() {
|
|
const lines = ['#EXTM3U'];
|
|
for (const ch of App.channels)
|
|
for (const m of ch.mirrors) {
|
|
const name = m.resolution ? `${ch.name} ${m.resolution}` : ch.name;
|
|
lines.push(`#EXTINF:-1 tvg-id="${escAttr(ch.id||'')}" tvg-logo="${escAttr(ch.logo_url||'')}" group-title="${escAttr(ch.group)}",${name}`);
|
|
lines.push(`acestream://${m.acestream_hash}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function downloadBlob(content, filename, mime) {
|
|
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ── Import ───────────────────────────────────────────────────────────────────
|
|
function triggerFileInput(type) {
|
|
$('importMenu').classList.add('hidden');
|
|
$(`${type}FileInput`).click();
|
|
}
|
|
|
|
async function handleImport(type, input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
input.value = '';
|
|
$('importMenu').classList.add('hidden');
|
|
showToast(`Importando ${file.name}…`);
|
|
try {
|
|
const text = await file.text();
|
|
const data = type === 'json' ? parseImportJSON(text, file.name) : parseImportM3U(text, file.name);
|
|
App.channels = data.channels;
|
|
App.groups = data.groups;
|
|
App.groupMeta = data.groupMeta;
|
|
App.activeGroup = null; App.activeSubcategory = null;
|
|
App.activeTags.clear(); App.activeCountries.clear(); App.expandedGroups.clear();
|
|
App.search = '';
|
|
$('searchInput').value = '';
|
|
$('sourceLabel').textContent = data.source || '';
|
|
renderAll();
|
|
showToast(`✓ ${data.channels.length} canales cargados`, false);
|
|
} catch (err) {
|
|
showToast(`✗ ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────────
|
|
function doExport(type) {
|
|
$('exportMenu').classList.add('hidden');
|
|
if (type === 'json') downloadBlob(buildExportJSON(), 'channels.json', 'application/json');
|
|
else downloadBlob(buildExportM3U(), 'channels.m3u', 'text/plain');
|
|
}
|
|
|
|
// ── Toast ────────────────────────────────────────────────────────────────────
|
|
let _toastTimer;
|
|
function showToast(msg, isError = false) {
|
|
const toast = $('toast');
|
|
$('toastMsg').textContent = msg;
|
|
toast.style.borderColor = isError
|
|
? 'rgba(239,68,68,0.4)'
|
|
: 'rgba(124,58,237,0.35)';
|
|
toast.style.color = isError ? '#fca5a5' : '#c4b5fd';
|
|
toast.classList.remove('opacity-0', 'translate-y-2');
|
|
toast.classList.add('opacity-100', 'translate-y-0');
|
|
clearTimeout(_toastTimer);
|
|
_toastTimer = setTimeout(() => {
|
|
toast.classList.remove('opacity-100', 'translate-y-0');
|
|
toast.classList.add('opacity-0', 'translate-y-2');
|
|
}, 3500);
|
|
}
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
|
async function init() {
|
|
try {
|
|
const res = await fetch('channels.json');
|
|
const data = await res.json();
|
|
App.channels = data.channels;
|
|
App.groups = (data.groups || []).map(g => typeof g === 'string' ? g : g.name).sort();
|
|
App.groupMeta = Object.fromEntries(
|
|
(data.groups || []).filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
|
|
);
|
|
$('sourceLabel').textContent = data.source || '';
|
|
// Superponer estados guardados en localStorage
|
|
App.channels.forEach(ch =>
|
|
ch.mirrors.forEach(m => {
|
|
const saved = localStorage.getItem(`ms_${m.acestream_hash}`);
|
|
if (saved) m.status = saved;
|
|
})
|
|
);
|
|
renderAll();
|
|
} catch (err) {
|
|
$('stateLoading').classList.add('hidden');
|
|
showToast('Error al cargar canales', true);
|
|
}
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|