Files
jstrdev--ps5directo/web/index.html
T
2026-10-16 13:30:42 +02:00

627 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PS5 Directo</title>
<link rel="icon" href="/favicon.ico">
<style>
:root{
--bg:#0e1116;--panel:#161b22;--panel2:#1c232c;--line:#2a323d;--text:#e6edf3;--muted:#8b97a5;
--accent:#3d8bfd;--accent2:#2f6fd0;--ok:#2ea043;--warn:#d29922;--err:#e5534b;--radius:12px;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
body{margin:0;color:var(--text);font:15px/1.45 "Segoe UI",system-ui,-apple-system,sans-serif;min-height:100vh;background-color:var(--bg);background-image:radial-gradient(900px 600px at 0% 0%,rgba(58,123,255,.13),transparent 60%),radial-gradient(800px 600px at 100% 100%,rgba(123,63,228,.12),transparent 60%),url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22260%22 height=%22260%22 viewBox=%220 0 260 260%22%3E%3Cg fill=%22none%22 stroke=%22%23ffffff%22 stroke-opacity=%22.075%22 stroke-width=%222.4%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpath transform=%22translate%2840 48%29 rotate%28-12%29%22 d=%22M0 -15.0 L13.0 7.5 L-13.0 7.5 Z%22/%3E%3Ccircle cx=%22150%22 cy=%2236%22 r=%2212.0%22/%3E%3Cpath transform=%22translate%28228 92%29 rotate%288%29%22 d=%22M-12.0 -12.0 L12.0 12.0 M12.0 -12.0 L-12.0 12.0%22/%3E%3Crect transform=%22translate%2896 128%29 rotate%2814%29%22 x=%22-11.0%22 y=%22-11.0%22 width=%2222%22 height=%2222%22 rx=%223%22/%3E%3Ccircle cx=%2230%22 cy=%22190%22 r=%2210.0%22/%3E%3Cpath transform=%22translate%28196 200%29 rotate%2818%29%22 d=%22M0 -12.7 L11.0 6.4 L-11.0 6.4 Z%22/%3E%3Crect transform=%22translate%28232 232%29 rotate%28-10%29%22 x=%22-9.0%22 y=%22-9.0%22 width=%2218%22 height=%2218%22 rx=%223%22/%3E%3Cpath transform=%22translate%28124 232%29 rotate%28-6%29%22 d=%22M-10.0 -10.0 L10.0 10.0 M10.0 -10.0 L-10.0 10.0%22/%3E%3Ccircle cx=%22186%22 cy=%22140%22 r=%228.0%22/%3E%3Cpath transform=%22translate%2858 194%29 rotate%2830%29%22 d=%22M0 -10.4 L9.0 5.2 L-9.0 5.2 Z%22/%3E%3C/g%3E%3C/svg%3E");background-attachment:fixed;background-size:auto,auto,260px 260px}
header{display:flex;align-items:center;gap:14px;padding:16px 24px;border-bottom:1px solid var(--line);position:sticky;top:0;background:rgba(14,17,22,.82);backdrop-filter:blur(6px);z-index:5}
header h1{font-size:19px;margin:0;font-weight:650;letter-spacing:.2px}
header .sub{color:var(--muted);font-size:13px}
.spacer{flex:1}
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);font-size:12.5px;color:var(--muted);white-space:nowrap}
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.dot.ok{background:var(--ok)}.dot.err{background:var(--err)}.dot.warn{background:var(--warn)}
main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;gap:18px}
.card{background:rgba(22,27,34,.86);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:var(--radius);padding:18px}
.card h2{font-size:15px;margin:0 0 12px;font-weight:600}
textarea,input,select{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit}
textarea{min-height:110px;resize:vertical;font-family:Consolas,"Cascadia Mono",monospace;font-size:13px}
textarea:focus,input:focus{outline:2px solid var(--accent2);border-color:transparent}
label{display:block;font-size:12.5px;color:var(--muted);margin:0 0 5px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
.row.tri{grid-template-columns:1fr 1fr 1fr}
.row3{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px}
button{background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font:inherit;cursor:pointer}
button:hover{border-color:#3b4553}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.primary:hover{background:var(--accent2)}
button.small{padding:5px 10px;font-size:13px}
button.danger{color:#ffb4ae}
.actions{display:flex;gap:10px;align-items:center;margin-top:14px;flex-wrap:wrap}
.hint{color:var(--muted);font-size:12.5px}
.jobs{display:grid;gap:12px}
.job{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px}
.job .top{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.job .title{font-weight:600;font-size:15px;word-break:break-all}
.chip{font-size:12px;padding:2px 9px;border-radius:999px;border:1px solid var(--line);color:var(--muted);text-transform:capitalize}
.chip.hecho{color:#7ee2a0;border-color:#2b6a3d}.chip.error{color:#ff9d96;border-color:#7a2d29}
.chip.enviando,.chip.extrayendo,.chip.colocando,.chip.comprobando{color:#9ec5ff;border-color:#2f5490}
.phase{color:var(--muted);font-size:13px;margin-top:6px;word-break:break-word}
.bar{height:8px;background:var(--bg);border-radius:6px;overflow:hidden;margin-top:9px;border:1px solid var(--line)}
.bar>i{display:block;height:100%;width:0;background:var(--accent);transition:width .6s}
.bar.ex>i{background:#8957e5}
.stats{display:flex;gap:16px;flex-wrap:wrap;font-size:12.5px;color:var(--muted);margin-top:6px}
.stats b{color:var(--text);font-weight:600}
.err{color:#ff9d96;font-size:13px;margin-top:8px;white-space:pre-wrap}
.result{font-size:13px;margin-top:8px;color:#7ee2a0;font-family:Consolas,monospace}
details{margin-top:8px}
summary{cursor:pointer;color:var(--muted);font-size:12.5px}
pre.log{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:10px;max-height:240px;overflow:auto;font-size:12px;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
.empty{color:var(--muted);text-align:center;padding:26px;border:1px dashed var(--line);border-radius:var(--radius)}
dialog{background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:14px;padding:0;width:min(720px,94vw)}
dialog::backdrop{background:rgba(0,0,0,.55)}
dialog .head{padding:16px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center}
dialog .head h2{margin:0;font-size:16px}
dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
.check{display:flex;gap:9px;align-items:center;margin-top:12px;font-size:14px;color:var(--text)}
.check input{width:auto}
/* --- guia de bienvenida --- */
.tour-spot{position:fixed;z-index:50;border-radius:12px;box-shadow:0 0 0 9999px rgba(5,8,14,.72),0 0 0 2px var(--accent),0 0 24px rgba(61,139,253,.55);transition:all .25s ease;pointer-events:none}
.tour-spot.none{box-shadow:0 0 0 9999px rgba(5,8,14,.72);width:0!important;height:0!important}
.tour-box{position:fixed;z-index:51;width:min(380px,calc(100vw - 32px));background:var(--panel2);border:1px solid #34405a;border-radius:14px;padding:16px 18px;box-shadow:0 18px 50px rgba(0,0,0,.5);transition:top .25s ease,left .25s ease}
.tour-box h3{margin:0 0 6px;font-size:16px}
.tour-box p{margin:0 0 12px;color:#c7d0db;font-size:14px}
.tour-box .tb-foot{display:flex;align-items:center;gap:8px}
.tour-box .tb-step{color:var(--muted);font-size:12px;margin-right:auto}
.tour-block{position:fixed;inset:0;z-index:49}
.toast{position:fixed;right:18px;bottom:18px;background:var(--panel2);border:1px solid var(--line);padding:10px 14px;border-radius:10px;opacity:0;transition:.3s;max-width:420px}
.toast.show{opacity:1}
.steps{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
.step{font-size:11.5px;color:var(--muted);padding:2px 8px;border-radius:6px;background:var(--bg);border:1px solid var(--line)}
.step.on{color:#9ec5ff;border-color:#2f5490}.step.done{color:#7ee2a0;border-color:#2b6a3d}
@media(max-width:720px){.row,.row3,.row.tri{grid-template-columns:1fr}header{flex-wrap:wrap}}
.drophint{font-size:12px;opacity:.7;margin-top:6px}
.drophint.flash{opacity:1;color:#ffcf6b}
.muted{opacity:.6}
.warn{color:#ffcf6b;font-size:13px;margin-top:6px}
.chk{display:flex;gap:8px;align-items:flex-start;padding:4px 0;font-size:13px}
.chk .ok{color:#4ade80}.chk .bad{color:#f87171}
.chip.pausado{color:#ffcf6b;border-color:#6b5a2b}
</style>
</head>
<body>
<header>
<svg width="40" height="40" style="flex:none;filter:drop-shadow(0 4px 12px rgba(75,79,230,.45))" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <defs> <linearGradient id="lg_bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stop-color="#3a7bff"/> <stop offset=".55" stop-color="#4b4fe6"/> <stop offset="1" stop-color="#7b3fe4"/> </linearGradient> <radialGradient id="lg_glow" cx=".3" cy=".2" r=".9"> <stop offset="0" stop-color="#fff" stop-opacity=".35"/> <stop offset=".5" stop-color="#fff" stop-opacity="0"/> </radialGradient> <linearGradient id="lg_arrow" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#ffffff"/> <stop offset="1" stop-color="#dfe6ff"/> </linearGradient> </defs> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_bg)"/> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_glow)"/> <rect x="9.5" y="9.5" width="237" height="237" rx="56.5" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="3"/> <g stroke="#fff" stroke-linecap="round" stroke-opacity=".45" stroke-width="9"> <line x1="62" y1="58" x2="62" y2="92"/> <line x1="194" y1="58" x2="194" y2="92"/> </g> <path d="M128 44 c9 0 15 6 15 15 v58 h22 c11 0 16 13 8 21 l-35 37 c-6 6-14 6-20 0 l-35-37 c-8-8-3-21 8-21 h22 v-58 c0-9 6-15 15-15z" fill="url(#lg_arrow)"/> <path d="M52 168 v18 c0 14 10 24 24 24 h104 c14 0 24-10 24-24 v-18" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/> <circle cx="176" cy="190" r="5.5" fill="#7dffb2"/> </svg>
<div>
<h1>PS5 Directo <span id="ver" style="font-size:12px;font-weight:400;opacity:.55"></span></h1>
<div class="sub">Envía archivos a tu consola por la red local, sin pasar por el disco del PC</div>
</div>
<div class="spacer"></div>
<span class="pill" id="pFtp"><span class="dot"></span>FTP</span>
<span class="pill" id="pBf"><span class="dot"></span>BFpilot</span>
<span class="pill" id="pFree" hidden></span>
<button class="small" id="btnTour" title="Guía rápida: para qué es cada cosa">Guía</button>
<button class="small" id="btnExtHead" title="Capturar enlaces desde el navegador sin copiar y pegar">Extensión</button>
<button class="small" id="btnUpdHead" title="Buscar o instalar una versión nueva">Actualizar</button>
<button class="small" id="btnSettings">Ajustes</button>
<button class="small" id="btnQuit" title="Cierra PS5 Directo del todo">Salir</button>
</header>
<main>
<section class="card">
<h2>Nueva transferencia</h2>
<label for="lines">Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…</label>
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar&#10;https://servidor/archivo.part2.rar&#10;&#10;También vale la ruta de un archivo de tu PC, por ejemplo: D:\Descargas\archivo.rar"></textarea>
<div class="drophint" id="drophint">Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el <b>icono de PS5 Directo</b>, o clic derecho → <b>Enviar a → PS5 Directo</b>.</div>
<div class="row tri">
<div>
<label for="pw">Contraseña del comprimido (si la tiene)</label>
<input id="pw" type="text" autocomplete="off" placeholder="Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre">
</div>
<div>
<label for="dest">Destino en la PS5</label>
<input id="dest" type="text" value="/data/homebrew" list="destList">
<datalist id="destList"><option value="/data/homebrew"></datalist>
</div>
<div>
<label for="where">Dónde descomprimir</label>
<select id="where">
<option value="ps5">En la PS5 (BFpilot)</option>
<option value="pc">En el PC (7-Zip)</option>
</select>
</div>
</div>
<div class="actions">
<button class="primary" id="btnAdd">Añadir a la cola</button>
<span class="hint">Si pegas enlaces de varios archivos distintos, los separa solo. «En el PC» usa 7-Zip y manda el contenido ya descomprimido y comprobado (más datos por el Wi-Fi, pero sin BFpilot).</span>
</div>
</section>
<section>
<div style="display:flex;align-items:center;margin-bottom:10px">
<h2 style="font-size:15px;margin:0">Cola</h2>
<div class="spacer"></div>
<button class="small" id="btnClear">Quitar terminados</button>
</div>
<div class="jobs" id="jobs"></div>
</section>
</main>
<dialog id="dlgSpeed">
<div class="head"><h2>Test de velocidad</h2><div class="spacer"></div><button class="small" id="btnSpeedClose">Cerrar</button></div>
<div class="body">
<p class="hint">Mide por separado la descarga desde el enlace, el envío del PC a la PS5 y las dos cosas a la vez (unos 30 segundos). Así se sabe de quién es la culpa cuando va lento. No se guarda nada: el archivo de prueba de la PS5 se borra al acabar.</p>
<div id="speedOut" style="margin-top:10px"></div>
</div>
</dialog>
<dialog id="dlg">
<div class="head"><h2>Ajustes</h2><div class="spacer"></div><button class="small" id="btnClose">Cerrar</button></div>
<div class="body">
<div class="row">
<div><label>IP de la PS5</label><input id="s_ps5_ip" placeholder="por ejemplo 192.168.1.50"></div>
<div><label>Destino por defecto</label><input id="s_dest"></div>
</div>
<div class="row3">
<div><label>Puerto FTP</label><input id="s_ftp_port" type="number"></div>
<div><label>Usuario FTP</label><input id="s_ftp_user"></div>
<div><label>Contraseña FTP</label><input id="s_ftp_pass" type="password" placeholder="(vacía)"></div>
<div><label>Puerto BFpilot</label><input id="s_bf_port" type="number"></div>
</div>
<div class="row3">
<div><label>Conexiones por archivo</label><input id="s_streams" type="number" min="1" max="32"></div>
<div><label>Partes a la vez (1-4)</label><input id="s_parallel_files" type="number" min="1" max="4"></div>
<div><label>Trozo (MB)</label><input id="s_chunk_mb" type="number" min="1" max="64"></div>
<div><label>Memoria (MB)</label><input id="s_buffer_mb" type="number" min="32" max="2048"></div>
<div><label>Reintentos</label><input id="s_retries" type="number" min="0" max="50"></div>
</div>
<div class="row3">
<div><label>Límite de velocidad a la PS5 (MB/s, 0 = sin límite)</label><input id="s_limit_mbps" type="number" min="0" max="1000"></div>
<div><label>Dejar siempre libre en la PS5 (GB)</label><input id="s_space_margin_gb" type="number" min="0" max="500"></div>
<div><label>Hilos al descomprimir (0 = automático, máx. 8)</label><input id="s_extract_threads" type="number" min="0" max="8"></div>
</div>
<p><button class="small" id="btnSpeedPS5">Test de velocidad PC → PS5</button> <span class="hint">Para incluir la descarga, usa "Test de velocidad" en una transferencia de la cola.</span></p>
<p class="hint">El límite se aplica al momento, sin parar nada. Si el espacio libre baja del margen, PS5 Directo para el envío o la descompresión antes de llenar la consola (0 = no vigilar). Con alojamientos que limitan conexiones usa solo 2 por archivo y baja más si el servidor protesta.</p>
<div style="margin-top:12px">
<label>Contraseñas que usas a menudo (una por línea). Se prueban solas si el trabajo no lleva contraseña.</label>
<textarea id="s_passwords" style="min-height:80px"></textarea>
</div>
<label class="check"><input type="checkbox" id="s_guess_passwords"> Probar también contraseñas sacadas del nombre del archivo (lo que va entre corchetes, como [WEB.COM])</label>
<label class="check"><input type="checkbox" id="s_delete_archives"> Borrar los comprimidos al terminar bien (de la PS5, o las partes bajadas al PC)</label>
<div style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
<b>Descomprimir en el PC</b>
<p class="hint">Alternativa a BFpilot: 7-Zip descomprime en el PC sobre la marcha y la app manda los archivos ya sueltos, comprobando cada uno con su CRC. No escribe nada descomprimido en el PC y en la PS5 solo ocupa el contenido final (sin el RAR al lado). A cambio, por el Wi-Fi viaja todo sin comprimir (puede ser 3-4 veces más) y los enlaces se bajan antes al PC (hace falta sitio para el RAR).</p>
<div class="row tri">
<div><label>Por defecto</label><select id="s_extract_where"><option value="ps5">En la PS5 (BFpilot)</option><option value="pc">En el PC (7-Zip)</option></select></div>
<div><label>Carpeta del PC para bajar los RAR</label><input id="s_pc_temp_dir" type="text" placeholder="Descargas\PS5 Directo (temporal)"></div>
<div><label>Ruta de 7z.exe (vacío = buscarlo)</label><input id="s_sevenzip_path" type="text" placeholder="C:\Program Files\7-Zip\7z.exe"></div>
</div>
</div>
<div class="actions">
<button class="primary" id="btnSave">Guardar</button>
<button id="btnTest">Probar conexión</button>
<button id="btnHealth" class="small">Revisión completa</button>
<span class="hint" id="testOut"></span>
<div id="healthOut" style="margin-top:8px"></div>
<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<b id="extSection">Extensión para capturar enlaces (Chrome / Edge)</b>
<p class="hint">Con ella, al pulsar Descargar en cualquier web (después del captcha), el enlace de un .rar/.7z/.zip/.pkg va directo aquí y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.<br>
Instalarla (una vez): 1) Abre <b>chrome://extensions</b> (o <b>edge://extensions</b>) · 2) Activa <b>Modo de desarrollador</b> · 3) <b>Cargar descomprimida</b> y elige la carpeta <b>extension</b> de PS5 Directo.</p>
<button id="btnExtFolder" class="small">Abrir la carpeta de la extensión</button> <span class="hint" id="extOut"></span>
</div>
<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<b>Actualizar PS5 Directo</b> <span class="hint" id="curVer"></span>
<p class="hint">Elige el .zip de la versión nueva y se instala solo (guarda una copia de la versión actual en la carpeta .backup). Se reinicia y se abre una ventana nueva.</p>
<input type="file" id="updFile" accept=".zip" hidden>
<button id="btnUpdZip" class="small">Instalar actualización (.zip)</button>
<div style="margin-top:8px"><label>GitHub para buscar actualizaciones (usuario/repositorio, opcional)</label><input id="s_update_repo" placeholder="por ejemplo: usuario/ps5directo"></div>
<button id="btnUpdCheck" class="small" style="margin-top:6px">Buscar actualización en GitHub</button>
<div id="updOut" class="hint" style="margin-top:6px"></div>
</div>
<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<b>Permisos de las aplicaciones</b>
<p class="hint">Lo que descomprimió BFpilot puede haber quedado sin permiso de ejecución y la PS5 no lo abre (error CE-107750-0). Esto pone permisos 777 a todas las aplicaciones en carpeta. No toca el contenido.</p>
<button id="btnPerms" class="small">Reparar permisos</button>
<div id="permsOut" class="hint" style="margin-top:8px"></div>
</div>
<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<b>Limpieza de la PS5</b>
<p class="hint">Busca en /data/ps5directo restos que ya no usa ninguna transferencia de la cola (partes de trabajos quitados, descompresiones a medias...). Te enseña cuánto ocupa cada cosa antes de borrar.</p>
<button id="btnScan" class="small">Buscar restos</button>
<div id="cleanOut" style="margin-top:8px"></div>
</div>
</div>
<p class="hint" style="margin-top:16px">Necesitas en la PS5: el servidor FTP (puerto 2121) y <b>BFpilot</b> cargado desde Payload Manager (puerto 5905). Para ir más rápido, con Wi-Fi baja “Trozo” a 2–4 MB.</p>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script>
const TOKEN = new URLSearchParams(location.hash.slice(1)).get('t') || '';
const $ = (id) => document.getElementById(id);
let state = {jobs:[], settings:{}};
let openLogs = new Set();
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove('show'),3500)}
async function api(path, body){
const r = await fetch(path,{method: body===undefined?'GET':'POST',headers:{'X-Token':TOKEN,'X-UI':'1','Content-Type':'application/json'},body: body===undefined?undefined:JSON.stringify(body)});
const j = await r.json().catch(()=>({ok:false,error:'HTTP '+r.status}));
if(!r.ok || j.ok===false) throw new Error(j.error||('HTTP '+r.status));
return j;
}
function human(n){if(n==null||n<0)return '?';const u=['B','KB','MB','GB','TB'];let i=0;n=Number(n);while(n>=1024&&i<u.length-1){n/=1024;i++}return (i?n.toFixed(1):n)+' '+u[i]}
function eta(sec){if(!isFinite(sec)||sec<=0)return '';const h=Math.floor(sec/3600),m=Math.floor(sec%3600/60),s=Math.floor(sec%60);return h?`${h} h ${m} min`:m?`${m} min ${s} s`:`${s} s`}
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
const ORDER=['comprobando','enviando','en cola para descomprimir','extrayendo','colocando','hecho'];
function pcStepsHtml(j){
// Modo "descomprimir en el PC": bajar al PC -> descomprimir y enviar -> colocar -> listo
const hasUrl=(j.sources||[]).some(x=>x.kind==='url');
const labels=(hasUrl?[['bajar','Bajar al PC']]:[]).concat([['enviar','Descomprimir y enviar'],['colocar','Colocar'],['hecho','Listo']]);
let cur = j.status==='hecho'?'hecho':(['colocando','en cola para descomprimir'].includes(j.status)?'colocar':(j.pc_step||'enviar'));
const keys=labels.map(x=>x[0]); const ci=keys.indexOf(cur);
return '<div class="steps">'+labels.map(([k,l],i)=>{const cls=j.status==='hecho'||i<ci?'done':(i===ci&&!['error','cancelado','pausado'].includes(j.status)?'on':'');return `<span class="step ${cls}">${l}</span>`}).join('')+'</div>';
}
function stepsHtml(st){
const labels=[['enviando','Enviar'],['extrayendo','Descomprimir'],['colocando','Colocar'],['hecho','Listo']];
const idx=ORDER.indexOf(st);
return '<div class="steps">'+labels.map(([k,l])=>{const ki=ORDER.indexOf(k);const cls= st==='hecho'||idx>ki?'done':(idx===ki||(k==='enviando'&&st==='comprobando')||(k==='extrayendo'&&st==='en cola para descomprimir'))?'on':'';return `<span class="step ${cls}">${l}</span>`}).join('')+'</div>';
}
const logScroll = {}; // id -> {top, bottom}
function render(){
const box=$('jobs');
// si estas seleccionando texto en la cola, no redibujar (para poder copiar)
const sel=window.getSelection();
if(sel && !sel.isCollapsed && box.contains(sel.anchorNode)) return;
box.querySelectorAll('pre.log').forEach(p=>{
const id=p.closest('.job').dataset.id;
logScroll[id]={top:p.scrollTop, bottom: p.scrollHeight-p.scrollTop-p.clientHeight<20};
});
if(!state.jobs.length){box.innerHTML='<div class="empty">No hay nada en la cola. Pega enlaces arriba.</div>';return}
box.innerHTML=state.jobs.map(j=>{
const pct = j.total>0? Math.min(100, j.sent*100/j.total):0;
const sending = j.status==='enviando';
const left = sending && j.speed>0 && j.total>0 ? eta((j.total-j.sent)/j.speed):'';
const files = (j.sources||[]).map(s=>`${esc(s.name)} (${human(s.size)})`).join('<br>');
const showEx = (['extrayendo','colocando'].includes(j.status) || j.extract_pct>0) && !j.pcx;
const canCancel = !['hecho','error','cancelado','pausado'].includes(j.status);
const canRetry = ['error','cancelado'].includes(j.status);
const canPause = ['pendiente','comprobando','enviando'].includes(j.status);
const paused = j.status==='pausado';
return `<div class="job" data-id="${j.id}">
<div class="top"><span class="title">${esc(j.title|| (j.sources&&j.sources[0]&&j.sources[0].name) || 'Transferencia')}</span>
<span class="chip ${esc(j.status.split(' ')[0])}">${esc(j.status)}</span><div class="spacer"></div>
${canPause?`<button class="small" data-act="pause">Pausar</button>`:''}
${paused?`<button class="small" data-act="retry">Reanudar</button>`:''}
${canCancel?`<button class="small danger" data-act="cancel">Cancelar</button>`:''}
${canRetry?`<button class="small" data-act="retry">Reintentar</button>`:''}
${(!canPause && (j.sources||[]).some(x=>x.kind==='url') && !['extrayendo','colocando'].includes(j.status))?`<button class="small" data-act="speed" title="Mide descarga, envío y las dos cosas a la vez">Test de velocidad</button>`:''}
${!canCancel?`<button class="small" data-act="remove">Quitar</button>`:''}
</div>
${(j.pc_step||j.pcx)?pcStepsHtml(j):stepsHtml(j.status)}
<div class="phase">${esc(j.phase)}</div>
<div class="bar"><i style="width:${j.status==='hecho'?100:pct.toFixed(1)}%"></i></div>
<div class="stats"><span>${esc(j.sent_label||'Enviado')} <b>${human(j.sent)}</b> de <b>${human(j.total)}</b></span>
${sending?`<span>${esc(j.speed_label||'Velocidad a la PS5')} <b>${human(j.speed)}/s</b></span>`:''}${sending&&j.dl_speed>0&&j.sources&&j.sources.some(x=>x.kind==='url')?`<span class="muted">descarga ${human(j.dl_speed)}/s</span>`:''}${left?`<span>Quedan <b>${left}</b></span>`:''}</div>
${showEx?`<div class="bar ex"><i style="width:${j.extract_pct||0}%"></i></div><div class="stats"><span>Descompresión en la PS5 <b>${j.extract_pct||0}%</b></span><span>${esc(j.extract_text||'')}</span></div>`:''}
${j.warning?`<div class="warn">${esc(j.warning)}</div>`:''}
${j.error?`<div class="err">${esc(j.error)}</div>`:''}
${(j.result||[]).length?`<div class="result">${j.result.map(esc).join('<br>')}</div>`:''}
<details ${openLogs.has(j.id)?'open':''} data-log="${j.id}"><summary>Archivos y registro</summary>
<div class="hint" style="margin-top:6px">${files||'—'}</div>
<pre class="log">${esc((j.log||[]).slice(-120).join('\n'))}</pre>
<button class="small" data-act="copylog" style="margin-top:6px">Copiar registro</button></details>
</div>`}).join('');
box.querySelectorAll('details').forEach(d=>d.addEventListener('toggle',()=>{
// al redibujar, un <details open> tambien dispara 'toggle': solo bajar si lo abres tu
if(d.open && !openLogs.has(d.dataset.log)){
openLogs.add(d.dataset.log);
const p=d.querySelector('pre.log'); if(p) p.scrollTop=p.scrollHeight;
} else if(!d.open) openLogs.delete(d.dataset.log);
}));
box.querySelectorAll('pre.log').forEach(p=>{
const st=logScroll[p.closest('.job').dataset.id];
p.scrollTop = (!st || st.bottom) ? p.scrollHeight : st.top; // sigue abajo si estabas abajo
});
}
$('jobs').addEventListener('click', async (ev)=>{
const b=ev.target.closest('button[data-act]'); if(!b) return;
const id=b.closest('.job').dataset.id, act=b.dataset.act; let body={};
if(act==='speed'){
const job=state.jobs.find(x=>x.id===id); const src=(job.sources||[]).find(x=>x.kind==='url');
speedTest(src?src.input:''); return;
}
if(act==='copylog'){
const job=state.jobs.find(x=>x.id===id);
const txt=[job.title, job.status, job.error||'', ...(job.sources||[]).map(s=>s.name+' ('+human(s.size)+')'), '', ...(job.log||[])].join('\n');
try{await navigator.clipboard.writeText(txt); toast('Registro copiado. Pégalo donde quieras (Ctrl+V).')}
catch(e){const t=document.createElement('textarea');t.value=txt;document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();toast('Registro copiado.')}
return;
}
if(act==='retry'){const job=state.jobs.find(x=>x.id===id); if(job && /contrase/i.test(job.error||'')){const p=prompt('Contraseña del comprimido (vacío = probar las guardadas):',''); if(p===null) return; body.password=p}}
if(act==='cancel' && !confirm('¿Cancelar esta transferencia? Lo ya enviado se conserva y se puede reintentar.')) return;
try{await api(`/api/job/${id}/${act}`, body); refresh()}catch(e){toast(e.message)}
});
$('btnAdd').onclick = async ()=>{
try{await api('/api/add',{lines:$('lines').value,password:$('pw').value,dest:$('dest').value,where:$('where').value});$('lines').value='';$('pw').value='';toast('Añadido a la cola');refresh()}
catch(e){toast(e.message)}
};
$('btnClear').onclick = async ()=>{await api('/api/clear',{}).catch(()=>{});refresh()};
const SKEYS=['ps5_ip','dest','extract_where','pc_temp_dir','sevenzip_path','ftp_port','ftp_user','ftp_pass','bf_port','streams','parallel_files','limit_mbps','space_margin_gb','extract_threads','update_repo','chunk_mb','buffer_mb','retries'];
$('btnSettings').onclick=()=>{const s=state.settings;SKEYS.forEach(k=>{if(k!=='ftp_pass')$('s_'+k).value=s[k]??''});$('s_ftp_pass').value='';$('s_passwords').value=(s.passwords||[]).join('\n');$('s_guess_passwords').checked=!!s.guess_passwords;$('s_delete_archives').checked=!!s.delete_archives;$('testOut').textContent='';$('dlg').showModal()};
$('btnClose').onclick=()=>$('dlg').close();
async function saveSettings(){const b={};SKEYS.forEach(k=>{const v=$('s_'+k).value;if(k==='ftp_pass'&&!v)return;b[k]=v});b.passwords=$('s_passwords').value.split('\n');b.guess_passwords=$('s_guess_passwords').checked;b.delete_archives=$('s_delete_archives').checked;await api('/api/settings',b)}
$('btnSave').onclick=async()=>{try{await saveSettings();toast('Guardado');$('dlg').close();refresh();checkConn()}catch(e){toast(e.message)}};
$('btnTest').onclick=async()=>{$('testOut').textContent='Probando…';try{await saveSettings();const r=await api('/api/test',{});$('testOut').textContent=`FTP: ${r.ftp} · BFpilot: ${r.bf}${r.free?' · libre: '+r.free:''}`;paintConn(r)}catch(e){$('testOut').textContent=e.message}};
function paintConn(r){
const set=(id,ok,txt)=>{const p=$(id);p.querySelector('.dot').className='dot '+(ok?'ok':'err');p.title=txt};
set('pFtp', r.ftp==='ok', 'FTP: '+r.ftp); set('pBf', String(r.bf).startsWith('ok'), 'BFpilot: '+r.bf);
if(r.free){$('pFree').hidden=false;$('pFree').textContent='Libre en PS5: '+r.free}
}
async function checkConn(){try{paintConn(await api('/api/test',{}))}catch(e){}}
async function refresh(){
try{const s=await api('/api/state');state=s;if(s.version){$('ver').textContent='v'+s.version;document.title='PS5 Directo v'+s.version}if(s.inbox>0)takeInbox();if(!$('dest').dataset.touched)$('dest').value=s.settings.dest||'/data/homebrew';if(!$('where').dataset.touched)$('where').value=s.settings.extract_where==='pc'?'pc':'ps5';render()}
catch(e){$('jobs').innerHTML=`<div class="empty">No conecto con el programa. ¿Cerraste la ventana negra? (${esc(e.message)})</div>`}
}
$('dest').addEventListener('input',()=>$('dest').dataset.touched='1');
$('where').addEventListener('change',()=>$('where').dataset.touched='1');
// --- guia de bienvenida (la primera vez, y con el boton "Guia")
const TOUR=[
{t:null,h:'Bienvenido a PS5 Directo',p:'Envía archivos a tu consola por la red local mientras se descargan, sin pasar por el disco del PC, y los deja descomprimidos y en su sitio. Te enseño en un minuto para qué es cada cosa.'},
{t:'#pFtp',h:'Estado de la consola',p:'Estos dos puntos dicen si la consola responde. FTP es por donde se envían los archivos; BFpilot es el que descomprime y coloca. Verde = listo. Rojo = carga el payload en la PS5 o revisa la IP en Ajustes.',pad:6,with:'#pBf'},
{t:'#lines',h:'Qué quieres enviar',p:'Pega aquí los enlaces directos, uno por línea (todas las partes .part1, .part2... juntas, en cualquier orden). También vale la ruta de un archivo de tu PC, o arrastrar archivos a esta ventana.'},
{t:'#pw',h:'Contraseña',p:'Si el comprimido lleva contraseña y la sabes, escríbela. Si la dejas vacía, prueba las que guardes en Ajustes y la que venga entre corchetes en el nombre del archivo.'},
{t:'#dest',h:'Destino',p:'Dónde acaba en la consola. /data/homebrew es la carpeta que escanea ShadowMountPlus para que aparezca en el menú. También puedes elegir un USB.'},
{t:'#where',h:'Dónde descomprimir',p:'En la PS5 (BFpilot): por el Wi-Fi viaja el comprimido, más rápido. En el PC (7-Zip): descomprime aquí sobre la marcha y comprueba cada archivo; viajan más datos, pero no depende de BFpilot.'},
{t:'#btnAdd',h:'A la cola',p:'Añade la transferencia. Puedes añadir varias: mientras una se descomprime en la consola, la siguiente ya se va enviando.'},
{t:'#jobs',h:'La cola',p:'Aquí ves cada transferencia: progreso, velocidad, tiempo restante y sus pasos. Puedes Pausar y Reanudar (sigue donde iba), Reintentar si algo falla y abrir "Archivos y registro" para ver el detalle.'},
{t:'#btnExtHead',h:'Extensión del navegador',p:'Opcional. Con ella, al pulsar Descargar en una web, el enlace llega aquí directo, sin copiar y pegar. Este botón abre su carpeta y las instrucciones.'},
{t:'#btnUpdHead',h:'Actualizar',p:'Busca la versión nueva e instálala con un clic. Guarda una copia de la anterior por si acaso.'},
{t:'#btnSettings',h:'Ajustes',p:'Lo primero: la IP de tu PS5. Ahí están también el límite de velocidad, el espacio que dejar libre, "Revisión completa" (comprueba que todo funciona), "Reparar permisos" y "Limpieza".'},
{t:'#btnTour',h:'¡Listo!',p:'Puedes volver a ver esta guía cuando quieras con este botón. Ahora vamos a poner la IP de tu PS5.'},
];
let tourI=0, tourEls=null, tourDone=null;
function tourEnd(){
if(tourEls){tourEls.forEach(e=>e.remove());tourEls=null}
window.removeEventListener('resize',tourPlace);
api('/api/settings',{tour_done:true}).catch(()=>{});
if(state.settings) state.settings.tour_done=true;
const cb=tourDone; tourDone=null; if(cb) cb();
}
function tourPlace(){
if(!tourEls) return;
const [blk,spot,box]=tourEls, st=TOUR[tourI];
const el=st.t&&document.querySelector(st.t);
box.querySelector('h3').textContent=st.h; box.querySelector('p').textContent=st.p;
box.querySelector('.tb-step').textContent=(tourI+1)+' de '+TOUR.length;
box.querySelector('[data-t=back]').hidden=tourI===0;
box.querySelector('[data-t=next]').textContent=tourI===TOUR.length-1?'Empezar':'Siguiente';
const bw=box.offsetWidth, bh=box.offsetHeight, W=innerWidth, H=innerHeight;
if(!el){spot.className='tour-spot none';spot.style.left=W/2+'px';spot.style.top=H/2+'px';
box.style.left=Math.max(16,(W-bw)/2)+'px';box.style.top=Math.max(16,(H-bh)/2)+'px';return}
let r=el.getBoundingClientRect();
if(st.with){const r2=document.querySelector(st.with).getBoundingClientRect();
r={left:Math.min(r.left,r2.left),top:Math.min(r.top,r2.top),right:Math.max(r.right,r2.right),bottom:Math.max(r.bottom,r2.bottom)}}
const pad=st.pad||8;
const L=r.left-pad,T=r.top-pad,Wd=(r.right-r.left)+pad*2,Ht=Math.min((r.bottom-r.top)+pad*2,H-T-8);
spot.className='tour-spot';Object.assign(spot.style,{left:L+'px',top:T+'px',width:Wd+'px',height:Math.max(Ht,24)+'px'});
let top=T+Math.max(Ht,24)+12; if(top+bh>H-12) top=Math.max(12,T-bh-12);
let left=Math.min(Math.max(12,L),W-bw-12);
box.style.left=left+'px'; box.style.top=top+'px';
}
function tourGo(i){
tourI=Math.max(0,Math.min(TOUR.length-1,i));
const st=TOUR[tourI], el=st.t&&document.querySelector(st.t);
if(el){el.scrollIntoView({block:'center',behavior:'instant'})}
requestAnimationFrame(tourPlace);
}
function startTour(onDone){
if(tourEls) return;
try{$('dlg').open&&$('dlg').close()}catch(e){}
tourDone=onDone||null;
const blk=document.createElement('div');blk.className='tour-block';
const spot=document.createElement('div');spot.className='tour-spot none';
const box=document.createElement('div');box.className='tour-box';
box.innerHTML='<h3></h3><p></p><div class="tb-foot"><span class="tb-step"></span><button class="small" data-t="skip">Saltar guía</button><button class="small" data-t="back">Atrás</button><button class="small primary" data-t="next">Siguiente</button></div>';
document.body.append(blk,spot,box); tourEls=[blk,spot,box];
box.querySelector('[data-t=skip]').onclick=tourEnd;
box.querySelector('[data-t=back]').onclick=()=>tourGo(tourI-1);
box.querySelector('[data-t=next]').onclick=()=>tourI===TOUR.length-1?tourEnd():tourGo(tourI+1);
window.addEventListener('resize',tourPlace);
tourGo(0);
}
function needIp(){ if(state.settings && !state.settings.ps5_ip){$('btnSettings').click();setTimeout(()=>{try{$('s_ps5_ip').focus()}catch(e){}},100);toast('Pon la IP de tu PS5 y pulsa Guardar')} }
$('btnTour').onclick=()=>startTour();
document.addEventListener('keydown',e=>{ if(!tourEls) return;
if(e.key==='Escape') tourEnd(); else if(e.key==='ArrowRight'||e.key==='Enter'){e.preventDefault();tourI===TOUR.length-1?tourEnd():tourGo(tourI+1)} else if(e.key==='ArrowLeft') tourGo(tourI-1)});
refresh().then(()=>{
if(state.settings && !state.settings.tour_done) startTour(needIp); else needIp();
checkUpdateQuiet();
}); checkConn();
setInterval(refresh, 1000); setInterval(checkConn, 20000);
</script>
<script>
['dragover','drop'].forEach(ev=>window.addEventListener(ev,async e=>{
if(!(e.dataTransfer&&[...e.dataTransfer.types].includes('Files'))) return;
e.preventDefault();
if(ev!=='drop') return;
const files=[...e.dataTransfer.files].filter(f=>f.size>0||f.type);
if(!files.length){toast('Eso parece una carpeta: suelta los archivos de dentro');return;}
const h=document.getElementById('drophint');
h.textContent='Buscando '+files.length+' archivo(s) en tu PC...';h.classList.add('flash');
try{
const r=await api('/api/locate',{files:files.map(f=>({name:f.name,size:f.size}))});
const ta=document.getElementById('lines');
const got=files.map(f=>r.found[f.name]).filter(Boolean);
if(got.length){ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+got.join('\n');}
const miss=files.filter(f=>!r.found[f.name]).map(f=>f.name);
h.textContent=got.length?('Añadidas '+got.length+' ruta(s). Revisa destino y pulsa "Añadir a la cola".'):'';
if(miss.length) h.textContent+=' No encontré: '+miss.join(', ')+'. Arrástralos sobre el icono de PS5 Directo o pega la ruta.';
}catch(err){h.textContent='No pude buscarlo: '+err.message;}
setTimeout(()=>h.classList.remove('flash'),6000);
}));
</script>
<script>
document.getElementById('btnQuit').addEventListener('click',async()=>{
const run=(state&&state.jobs||[]).some(j=>!['hecho','error','cancelado'].includes(j.status));
if(!confirm(run?'Hay trabajos en marcha. Si sales se paran (luego puedes darle a Reintentar y siguen donde iban). ¿Salir?':'¿Cerrar PS5 Directo?'))return;
try{await api('/api/quit',{})}catch(e){}
document.body.innerHTML='<p style="padding:40px;font-family:sans-serif">PS5 Directo se ha cerrado. Ya puedes cerrar esta ventana.</p>';
setTimeout(()=>window.close(),800);
});
</script>
<script>
async function speedTest(url){
const out=document.getElementById('speedOut'); const d=document.getElementById('dlgSpeed');
out.innerHTML='<b>Midiendo…</b> (unos '+(url?30:10)+' segundos; no cierres el programa)'; d.showModal();
try{await api('/api/speedtest',{url})}catch(e){out.textContent=e.message;return}
const t0=Date.now();
while(true){
await new Promise(r=>setTimeout(r,1500));
let r; try{r=await api('/api/speedtest/result',{})}catch(e){out.textContent=e.message;return}
if(!r.running && r.result){
const x=r.result, f=v=>v==null?'—':v.toFixed(1)+' MB/s';
out.innerHTML=`<table style="width:100%;font-size:14px;border-collapse:collapse">
<tr><td>Descarga sola</td><td style="text-align:right"><b>${f(x.dl)}</b></td></tr>
<tr><td>PC → PS5 solo</td><td style="text-align:right"><b>${f(x.up)}</b></td></tr>
<tr><td>Las dos a la vez: descarga / envío</td><td style="text-align:right">${f(x.both_dl)} / ${f(x.both_up)}</td></tr>
<tr><td>Llegaría a la PS5 descargando y enviando</td><td style="text-align:right"><b>${f(x.pipe)}</b></td></tr>
</table><p style="margin-top:12px">${esc(x.verdict)}</p>${x.error?`<div class="err">${esc(x.error)}</div>`:''}`;
return;
}
out.innerHTML='<b>Midiendo…</b> '+Math.round((Date.now()-t0)/1000)+' s';
}
}
document.getElementById('btnSpeedClose').onclick=()=>document.getElementById('dlgSpeed').close();
document.getElementById('btnSpeedPS5').onclick=()=>{document.getElementById('dlg').close();speedTest('')};
</script>
<script>
// --- revision completa
$('btnHealth').onclick=async()=>{
const o=$('healthOut'); o.textContent='Revisando…';
try{await saveSettings(); const r=await api('/api/health',{});
o.innerHTML=r.checks.map(c=>`<div class="chk"><span class="${c.ok?'ok':'bad'}">${c.ok?'✔':'✘'}</span><div><b>${esc(c.name)}</b>: ${esc(c.detail)}${c.fix?`<div class="hint">${esc(c.fix)}</div>`:''}</div></div>`).join('');
}catch(e){o.textContent=e.message}
};
// --- permisos
$('btnPerms').onclick=async()=>{
const o=$('permsOut'); o.textContent='Revisando… (puede tardar un poco si hay muchas)';
try{const r=await api('/api/fixperms',{});
if(!r.results.length){o.textContent='No hay aplicaciones en carpeta.';return}
o.innerHTML=r.results.map(x=>x.error?`${esc(x.path)}: <span class="err">${esc(x.error)}</span>`:
`${esc(x.path)}: ${x.changed?`<b>arreglados ${x.changed}</b> de ${x.seen}${x.eboot.length?` (eboot.bin tenía ${esc(x.eboot[0])})`:''}`:'ya estaba bien'}`).join('<br>');
}catch(e){o.textContent=e.message}
};
// --- limpieza
let cleanItems=[];
$('btnScan').onclick=async()=>{
const o=$('cleanOut'); o.textContent='Buscando… (puede tardar unos segundos)';
try{const r=await api('/api/cleanup/scan',{}); cleanItems=r.items;
if(!cleanItems.length){o.textContent='No hay nada que limpiar.';return}
o.innerHTML=cleanItems.map((it,i)=>`<label class="chk"><input type="checkbox" data-i="${i}" ${it.in_use?'':'checked'}><div>${esc(it.label)} — <b>${human(it.size)}</b>${it.in_use?`<div class="hint">Lo usa "${esc(it.in_use)}" de la cola: si lo borras, esa transferencia tendrá que enviarse otra vez.</div>`:''}</div></label>`).join('')
+`<button id="btnClean" class="small danger" style="margin-top:8px">Borrar seleccionados</button>`;
$('btnClean').onclick=async()=>{
const sel=[...o.querySelectorAll('input[data-i]:checked')].map(x=>cleanItems[+x.dataset.i]);
if(!sel.length) return;
const tot=sel.reduce((a,b)=>a+(b.size||0),0);
if(!confirm(`¿Borrar ${sel.length} elemento(s) de la PS5 (${human(tot)})? No se puede deshacer.`)) return;
o.textContent='Borrando…';
try{const d=await api('/api/cleanup/delete',{paths:sel.map(x=>x.path)}); o.textContent=`Borrado: ${d.deleted.length} elemento(s), ${human(tot)} liberados.`; checkConn()}catch(e){o.textContent=e.message}
};
}catch(e){o.textContent=e.message}
};
// --- destinos segun unidades montadas
async function loadDests(){try{const r=await api('/api/places',{});$('destList').innerHTML=r.dests.map(d=>`<option value="${esc(d)}">`).join('')}catch(e){}}
loadDests(); setInterval(loadDests, 60000);
// --- avisos de Windows al terminar o fallar
const lastStatus={};
function notifyChanges(){
for(const j of (state.jobs||[])){
const prev=lastStatus[j.id]; lastStatus[j.id]=j.status;
if(!prev || prev===j.status) continue;
if(j.status==='hecho'||j.status==='error'){
const title=j.status==='hecho'?'Listo en la PS5':'Problema en PS5 Directo';
const body=(j.title||'Transferencia')+(j.status==='error'?': '+(j.error||'').slice(0,120):'');
try{ if('Notification' in window && Notification.permission==='granted') new Notification(title,{body,icon:'/favicon.ico'}); }catch(e){}
}
}
}
setInterval(notifyChanges, 1500);
document.addEventListener('click',()=>{try{if('Notification' in window && Notification.permission==='default') Notification.requestPermission()}catch(e){}},{once:true});
</script>
<script>
// --- enlaces que llegan de la extension del navegador
let taking=false;
async function takeInbox(){
if(taking) return; taking=true;
try{const r=await api('/api/inbox/take',{}); const ta=$('lines');
const have=new Set(ta.value.split('\n').map(x=>x.trim()).filter(Boolean));
const add=r.items.filter(i=>!have.has(i.url));
if(add.length){ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+add.map(i=>i.url).join('\n');
toast('Recibido de la extensión: '+add.map(i=>i.name||'enlace').join(', ')+'. Cuando estén todas las partes, pulsa "Añadir a la cola".');
ta.scrollIntoView({block:'center'});}
}catch(e){} finally{taking=false}
}
// --- extension
$('btnExtFolder').onclick=async()=>{try{const r=await api('/api/open_ext_folder',{});$('extOut').textContent=r.ok?('Carpeta: '+r.path):('Está en: '+r.path)}catch(e){$('extOut').textContent=e.message}};
// --- actualizar
$('btnSettings').addEventListener('click',()=>{$('curVer').textContent=state.version?('(tienes la v'+state.version+')'):''});
$('btnUpdZip').onclick=()=>$('updFile').click();
$('updFile').onchange=async()=>{
const f=$('updFile').files[0]; if(!f) return; const o=$('updOut');
if(!confirm('¿Instalar '+f.name+'? PS5 Directo se reiniciará.')) {$('updFile').value='';return}
o.textContent='Instalando…';
const b64=await new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(String(r.result).split(',')[1]);r.onerror=rej;r.readAsDataURL(f)});
try{const r=await api('/api/update/zip',{data:b64}); afterUpdate(r.version)}catch(e){o.textContent=e.message;toast(e.message)}
$('updFile').value='';
};
$('btnUpdCheck').onclick=async()=>{
const o=$('updOut'); o.textContent='Buscando…';
try{await saveSettings(); const r=await api('/api/update/check',{});
if(!r.ok){o.textContent=r.error;return}
if(!r.newer){o.textContent='Ya tienes la última (v'+r.current+').';return}
o.innerHTML='Hay una versión nueva: <b>v'+esc(r.latest)+'</b> (tienes la v'+esc(r.current)+'). <button class="small" id="btnUpdGo">Actualizar ahora</button>';
$('btnUpdGo').onclick=async()=>{o.textContent='Descargando e instalando…';try{const x=await api('/api/update/github',{});afterUpdate(x.version)}catch(e){o.textContent=e.message}};
}catch(e){o.textContent=e.message}
};
// --- botones de la cabecera
function jobsRunning(){return (state.jobs||[]).some(j=>['comprobando','enviando','extrayendo','colocando'].includes(j.status))}
$('btnExtHead').onclick=async()=>{
$('btnSettings').click();
setTimeout(()=>$('extSection').scrollIntoView({block:'start'}),50);
try{await $('btnExtFolder').onclick()}catch(e){}
};
let latestSeen=null;
async function checkUpdateQuiet(){
if(!state.repo) return;
try{const r=await api('/api/update/check',{}); if(r.ok&&r.newer){latestSeen=r.latest;$('btnUpdHead').textContent='Actualizar · v'+r.latest;$('btnUpdHead').classList.add('primary')}}catch(e){}
}
$('btnUpdHead').onclick=async()=>{
if(jobsRunning() && !confirm('Hay transferencias en marcha. Actualizar reinicia el programa y tendrás que pulsar Reintentar en ellos (siguen donde iban). ¿Seguir?')) return;
if(!state.repo){
toast('Elige el .zip de la versión nueva');
$('updFile').click(); return;
}
try{const r=await api('/api/update/check',{});
if(!r.ok){toast(r.error);return}
if(!r.newer){
if(confirm('Ya tienes la última (v'+r.current+'). ¿Quieres instalar una desde un .zip?')) $('updFile').click();
return}
if(!confirm('Hay una versión nueva: v'+r.latest+' (tienes la v'+r.current+').'+(r.notes?'\n\n'+r.notes.slice(0,600):'')+'\n\n¿Instalarla ahora?')) return;
toast('Descargando e instalando…');
const x=await api('/api/update/github',{}); afterUpdate(x.version);
}catch(e){toast(e.message)}
};
function afterUpdate(v){
document.body.innerHTML='<div style="padding:40px;font-family:system-ui,sans-serif;color:#ddd;background:#0d1117;min-height:100vh"><h2>Actualizado a la v'+esc(v)+'</h2><p>PS5 Directo se está reiniciando y abrirá una ventana nueva en unos segundos. Ya puedes cerrar esta.</p></div>';
}
</script>
</body>
</html>