mirror of
https://github.com/jstrdev/ps5directo.git
synced 2026-10-28 22:01:15 +01:00
627 lines
47 KiB
HTML
627 lines
47 KiB
HTML
<!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 https://servidor/archivo.part2.rar 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|