Versión 1.14.0

This commit is contained in:
JesterDev authored and GitHub committed 2026-10-16 13:30:42 +02:00
1 parent c48e8674d8
commit fdc42abe0a
18 files changed
+4566 -2

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$w=New-Object -ComObject WScript.Shell; $d=(Get-Location).Path;" ^
"foreach($f in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('SendTo'))){" ^
" $l=$w.CreateShortcut((Join-Path $f 'PS5 Directo.lnk'));" ^
" $l.TargetPath=(Join-Path $env:WINDIR 'System32\wscript.exe');" ^
" $l.Arguments='\"'+(Join-Path $d 'PS5 Directo.vbs')+'\"';" ^
" $l.WorkingDirectory=$d; $l.IconLocation=(Join-Path $d 'web\ps5directo.ico');" ^
" $l.Description='PS5 Directo'; $l.Save() }"
if %errorlevel%==0 (
echo Listo:
echo - Icono "PS5 Directo" en el escritorio. Tambien puedes soltar archivos encima.
echo - Clic derecho en archivos ^> Enviar a ^> PS5 Directo.
) else (
echo No se pudieron crear los accesos directos.
)
pause
+14
View File
@@ -0,0 +1,14 @@
@echo off
chcp 65001 >nul
title PS5 Directo
cd /d "%~dp0"
where py >nul 2>nul
if %errorlevel%==0 (
py -3 app.py %*
) else (
python app.py %*
)
if %errorlevel%==3 exit /b 0
echo.
echo PS5 Directo se ha cerrado.
pause
+263
View File
@@ -0,0 +1,263 @@
PS5 DIRECTO 1.14
================
Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
1. Descarga los archivos y los manda a la PS5 por FTP mientras bajan
(nada se guarda en el disco del PC).
2. Une las partes troceadas (.001, .002...) en un solo archivo.
3. Pide a BFpilot que descomprima el RAR/7z/ZIP DENTRO de la consola,
probando las contraseñas que conozca.
4. Busca la carpeta de la aplicación (la que tiene sce_sys) y la deja en
/data/homebrew, donde ShadowMountPlus la detecta sola.
5. Borra los comprimidos de la PS5.
Mientras una transferencia se descomprime en la PS5, la siguiente de la cola ya se
va enviando.
ALTERNATIVA: DESCOMPRIMIR EN EL PC (7-Zip)
En "Dónde descomprimir" elige "En el PC (7-Zip)" (o ponlo por defecto en
Ajustes). Entonces:
- Los enlaces se bajan antes al PC (carpeta temporal de Ajustes; hace
falta sitio para el RAR, no para lo descomprimido). Tus archivos locales se
usan tal cual y nunca se borran.
- 7-Zip descomprime sobre la marcha y la app manda cada archivo ya
suelto a la PS5. Lo descomprimido NO se escribe en el PC.
- Cada archivo se comprueba con su CRC antes de darlo por bueno.
- En la PS5 solo ocupa el contenido final (sin el RAR al lado) y se
sabe exacto antes de empezar.
- Pega: por el Wi-Fi viaja todo sin comprimir (puede ser 3-4 veces
más que el RAR), así que tarda más en enviarse.
- Pausar/Reintentar sigue por el archivo donde iba.
Necesita 7-Zip instalado (gratis, 7-zip.org). No necesita BFpilot.
LO QUE NECESITAS
----------------
Esta guía no explica cómo hacer el jailbreak: das por hecho que tu PS5 ya
lo tiene y que sabes cargar payloads (.elf).
EN EL PC
- Windows 10 u 11.
- Python 3.9 o superior: https://www.python.org/downloads/
Al instalarlo marca "Add python.exe to PATH".
- 7-Zip (solo si usas "Descomprimir en el PC"): https://www.7-zip.org/
EN LA PS5 (se cargan como payloads, por ejemplo con Payload Manager:
https://github.com/itsPLK/ps5-payload-manager )
- Un servidor FTP. El de referencia es ftpsrv (puerto 2121):
https://github.com/ps5-payload-dev/ftpsrv
Muchos menús de payloads ya lo traen. Si el tuyo usa otro puerto,
cámbialo en Ajustes.
- BFpilot (puerto 5905): descomprime dentro de la consola, mueve y borra.
Descarga bfpilot.elf de: https://github.com/ItsBlurf/BFpilot/releases
Si no está cargado, el programa espera a que lo cargues. En el modo
"Descomprimir en el PC" no hace falta.
- ShadowMountPlus: es el que hace que las aplicaciones de /data/homebrew
aparezcan en el menú de la PS5:
https://github.com/drakmor/ShadowMountPlus
Para comprobar que todo está bien: Ajustes > "Revisión completa".
SOBRE BFPILOT Y LOS PERMISOS (importante si lo usas por tu cuenta)
-----------------------------------------------------------------
Hasta su versión 0.4.4 al menos, cuando BFpilot descomprime un RAR, 7z o
ZIP deja los archivos sin permiso de ejecución, y la PS5 se niega a abrir
la aplicación (error CE-107750-0). Pasa también si descomprimes desde la web de
BFpilot, sin PS5 Directo.
- PS5 Directo lo arregla solo después de cada descompresión.
- Para lo que ya tenías en la consola: Ajustes > "Reparar permisos".
- A mano: en FileZilla, clic derecho en la carpeta de la aplicación >
Permisos de archivo > 777 > "Recurrir a subdirectorios".
PRIMERA VEZ
-----------
Doble clic en "Crear accesos directos.bat". Crea:
- Un icono "PS5 Directo" en el escritorio.
- "Enviar a > PS5 Directo" en el clic derecho de los archivos.
Desde entonces abre PS5 Directo con el icono del escritorio: sale en su
propia ventana, sin pestañas y sin ventana negra.
(Si algo falla al abrir, usa "Iniciar PS5 Directo.bat": es lo mismo pero
con la ventana negra, donde se ven los errores. Registro sin ventana:
%USERPROFILE%\.ps5directo\ps5directo.log)
Si cierras la ventana con trabajos en marcha, siguen en segundo plano.
Vuelve a abrir PS5 Directo para verlos. El botón "Salir" lo cierra del
todo (lo que esté a medias sigue luego con "Reintentar").
CÓMO SE USA
-----------
1. Descomprime esta carpeta donde quieras.
2. Abre PS5 Directo con el icono del escritorio.
3. La primera vez se abre Ajustes: pon la IP de tu PS5 (la ves en la
PS5 en Ajustes > Red > Ver estado de la conexión), pulsa "Guardar" y
"Probar conexión". Los dos puntos de arriba deben ponerse en verde.
4. Pega los enlaces directos (todas las partes juntas, en el
orden que sea) y pulsa "Añadir a la cola".
- Si sabes la contraseña, escríbela. Si no, prueba las de Ajustes y
lo que vaya entre corchetes en el nombre del archivo, como [WEB.COM].
- También vale la ruta de un archivo que ya tengas en el PC.
5. ARCHIVOS DEL PC SIN COPIAR RUTAS:
- Arrastra uno o varios archivos sobre el icono del escritorio.
- O clic derecho en los archivos > Enviar a > PS5 Directo.
Si PS5 Directo ya está abierto, se añaden a su cola.
- O suéltalos en la página del navegador: PS5 Directo los busca en
tu PC por nombre y tamaño y pone sus rutas en el cuadro de enlaces.
PAUSA, LÍMITE Y ESPACIO
-----------------------
- "Pausar" detiene el envío; "Reanudar" sigue donde iba.
- Ajustes > Límite de velocidad (MB/s): para usar internet mientras envía.
Se aplica al momento. 0 = sin límite.
- Ajustes > Dejar siempre libre (GB, 25 por defecto): antes de empezar
comprueba que cabe lo que falta por enviar, y mientras envía o
descomprime vigila el espacio. Si baja de ese margen, lo para todo
antes de que la PS5 se llene. Libera espacio y pulsa Reintentar.
- Algunos alojamientos de archivos limitan las conexiones: con ellos usa
solo 2 por archivo, y menos si el servidor empieza a dar errores.
ACTUALIZAR Y EXTENSIÓN
----------------------
- Botón "Actualizar" (arriba): si el programa tiene un repositorio de
GitHub configurado, busca la versión nueva y la instala. Si no, te
deja elegir el .zip de la versión nueva. Guarda una copia de la versión
anterior en la carpeta .backup.
- Botón "Extensión" (arriba): abre la carpeta de la extensión para
Chrome/Edge y las instrucciones. Con ella, al pulsar Descargar en una
web, el enlace va directo a PS5 Directo sin copiar y pegar.
TEST DE VELOCIDAD
-----------------
En una transferencia pausada o terminada, "Test de velocidad" mide (unos 30 s):
descarga sola, envio PC -> PS5 solo y las dos cosas a la vez, y te dice
de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
Ajustes.
NOVEDADES 1.14
- Guía de bienvenida la primera vez que se abre: enseña para qué es cada
parte. Se puede volver a ver con el botón "Guía" de arriba.
NOVEDADES 1.13.1
- El logo de la cabecera va dentro de la página (al actualizar desde una
versión anterior no se copiaba y salía roto).
NOVEDADES 1.13
- Icono nuevo y fondo con símbolos. Para que el icono nuevo salga en el
escritorio, vuelve a ejecutar "Crear accesos directos.bat".
NOVEDADES 1.12.1
- Textos más neutros: "transferencias" y "aplicaciones", como una
herramienta de transferencia de archivos.
NOVEDADES 1.12
- Botones "Actualizar" y "Extensión" en la pantalla principal. Si hay
versión nueva en GitHub, el botón lo avisa.
- La primera vez pide la IP de la PS5 (ya no trae una puesta).
- Guía de lo que hace falta y de dónde sacarlo (arriba).
NOVEDADES 1.11
- ARREGLO IMPORTANTE: lo descomprimido con BFpilot no arrancaba
(CE-107750-0). Su unrar deja los archivos con los permisos que trae el
RAR (666/444, sin permiso de ejecución). Ahora PS5 Directo les pone 777
justo al colocarlos, y lo apunta en el registro.
- Ajustes > "Reparar permisos": arregla las aplicaciones que ya tienes en la
consola (los que pasaste antes con BFpilot) sin volver a bajarlos.
- Modo PC: si BFpilot no está cargado, lo coloca por FTP (ya no
hace falta BFpilot para nada en ese modo).
NOVEDADES 1.10.1
- En el modo PC los pasos son "Bajar al PC → Descomprimir y enviar → Colocar
→ Listo", y dice por qué archivo va (37 de 129...).
NOVEDADES 1.10
- Nueva opción: descomprimir en el PC con 7-Zip y mandar el contenido ya
suelto, comprobado archivo a archivo (ver arriba).
- La "Revisión completa" dice si encuentra 7-Zip.
NOVEDADES 1.9
-------------
- EXTENSION PARA CAPTURAR ENLACES (Chrome o Edge), en la carpeta "extension":
1. Abre chrome://extensions (o edge://extensions).
2. Activa "Modo de desarrollador" (arriba a la derecha).
3. "Cargar descomprimida" y elige la carpeta "extension" de PS5 Directo.
Desde entonces, pasa el captcha y pulsa Descargar como siempre: si es un
.rar/.7z/.zip/.pkg y PS5 Directo esta abierto, el enlace bueno (despues de
redirecciones) aparece en el cuadro de enlaces y el navegador NO descarga
nada. Cuando esten todas las partes, pulsa "Anadir a la cola".
Si PS5 Directo esta cerrado, la descarga sigue normal. Se apaga y enciende
desde su icono.
- ACTUALIZAR: Ajustes > "Instalar actualizacion (.zip)": eliges el zip nuevo y
se instala solo, guarda copia de la version anterior en .backup y se
reinicia. Si algun dia lo subes a GitHub, pon "usuario/repositorio" y el
boton "Buscar actualizacion" lo descarga de las releases.
- La version sale arriba, junto al titulo, y en el nombre de la ventana.
NOVEDADES 1.8
-------------
- Mientras descomprime ves "leido del RAR X de Y" (lo que BFpilot ha
procesado del comprimido) y "descomprimido" (lo que ocupa de verdad en
la PS5). Una aplicación en formato carpeta puede ocupar varias veces el RAR:
Lost Records, 35 GB de RAR -> 145 GB de carpeta.
- Las previsiones de espacio cuentan con hasta ~5 veces el tamano del RAR.
- Si el RAR trae la aplicación dentro de una carpeta, se coloca igual como
TITLEID-app.
- La version aparece arriba, junto al titulo.
NOVEDADES 1.7
-------------
- Ajustes > Revision completa: comprueba FTP, escritura, BFpilot, formatos
y espacio, y dice que hacer si algo falla.
- Ajustes > Limpieza de la PS5: busca restos en /data/ps5directo, ensena
cuanto ocupan y los borra (nunca toca nada fuera de esa carpeta).
- Aviso si el Title ID ya existe en /data/homebrew o en un USB/externo.
- Destino: sugiere /mnt/usbX/homebrew si hay unidades conectadas.
- Ajustes > Hilos al descomprimir (0 = automatico). Si una descompresion
se cuelga, prueba con 1.
- Si la descompresion no da senales de vida en 5 min, lo avisa.
- Aviso de Windows al terminar o fallar (acepta el permiso la 1a vez).
SI ALGO FALLA
-------------
- Se corta la red o el FTP: reintenta solo y continúa donde se quedó.
- Cierras el programa a medias: al abrirlo, pulsa "Reintentar".
Continúa desde lo que ya había en la consola.
- Contraseña incorrecta: pulsa "Reintentar" y escríbela.
No se vuelve a descargar nada.
- "Ya existe": no pisa nada que ya tengas. Bórralo o renómbralo y
pulsa "Reintentar".
- Los archivos de trabajo están en /data/ps5directo (fuera de las
carpetas que escanea ShadowMountPlus).
ENLACES
-------
Funcionan los enlaces de descarga directa. Pixeldrain, Dropbox y algunos
de Mediafire y Google Drive se convierten solos. Las páginas que piden
captcha o iniciar sesión no funcionan: copia el enlace final de descarga.
Los .pkg se dejan en /data/pkg para instalarlos con ps5upload.
Las imágenes (.exfat, .ffpkg, .ffpfsc) van a /data/homebrew.
Ajustes y cola se guardan en %USERPROFILE%\.ps5directo
AVISO
-----
PS5 Directo es un programa independiente, sin relación con Sony ni con
los autores de BFpilot, ftpsrv o ShadowMountPlus. Es una herramienta de
transferencia de archivos: no incluye contenido de terceros, claves ni
archivos de Sony, ni enlaces a sitios de descargas. Úsalo solo con
contenido que tengas derecho a usar (tus copias de seguridad y homebrew).
Cada uno es responsable de lo que transfiere.
+21
View File
@@ -0,0 +1,21 @@
' Abre PS5 Directo sin ventana negra (en su propia ventana de aplicacion).
' Puedes arrastrar archivos encima de este archivo o de su acceso directo.
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
dir = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = dir
args = ""
For Each a In WScript.Arguments
args = args & " """ & a & """"
Next
py = ""
If sh.Run("cmd /c where pyw >nul 2>nul", 0, True) = 0 Then
py = "pyw -3"
ElseIf sh.Run("cmd /c where pythonw >nul 2>nul", 0, True) = 0 Then
py = "pythonw"
End If
If py = "" Then
MsgBox "No encuentro Python. Instala Python 3 (marca 'Add to PATH') o usa 'Iniciar PS5 Directo.bat'.", 48, "PS5 Directo"
Else
sh.Run py & " """ & dir & "\app.py""" & args, 0, False
End If
+29 -2
View File
@@ -1,2 +1,29 @@
# ps5directo
Herramienta de transferencia de archivos a PS5 por red local
# PS5 Directo
Herramienta de transferencia de archivos para Windows: envía archivos a una PS5 con jailbreak por FTP mientras se descargan, sin pasar por el disco del PC. Después los descomprime (en la consola con BFpilot, o en el PC con 7-Zip) y coloca las aplicaciones en `/data/homebrew` para que ShadowMountPlus las muestre en el menú.
- Descarga en paralelo, con pausa, reanudación y límite de velocidad.
- Vigila el espacio libre de la PS5 para no llenarla.
- Corrige los permisos de lo descomprimido con BFpilot. Sin esa corrección no arranca (CE-107750-0).
- Extensión opcional para Chrome/Edge que manda los enlaces directamente al programa.
- Se actualiza desde las releases de este repositorio.
## Requisitos
- **PC:** Windows 10/11, [Python 3.9+](https://www.python.org/downloads/) y, para descomprimir en el PC, [7-Zip](https://www.7-zip.org/).
- **PS5 con jailbreak:**
- Servidor FTP, por ejemplo [ftpsrv](https://github.com/ps5-payload-dev/ftpsrv) en el puerto 2121.
- [BFpilot](https://github.com/ItsBlurf/BFpilot) en el puerto 5905.
- [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus).
## Uso
1. Descarga el `.zip` de la última [release](../../releases) y descomprímelo.
2. Ejecuta `Crear accesos directos.bat` y abre **PS5 Directo** desde el escritorio.
3. Pon la IP de tu PS5 en Ajustes y pega los enlaces o arrastra los archivos.
La guía completa está en [LEEME.txt](LEEME.txt).
## Aviso
Proyecto independiente, sin relación con Sony ni con los autores de las herramientas citadas. No incluye contenido de terceros, claves ni archivos de Sony, ni enlaces a sitios de descargas. Úsalo solo con contenido que tengas derecho a usar (copias de seguridad propias y homebrew).
+2204
View File
File diff suppressed because it is too large. Load diff
+1233
View File
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
// PS5 Directo - captura de enlaces.
// Cuando el navegador empieza a descargar un archivo comprimido o paquete (.rar, .7z, .zip, .pkg...),
// manda el enlace FINAL (despues de redirecciones) a PS5 Directo y cancela la descarga.
// Si PS5 Directo no esta abierto, no toca nada: la descarga sigue normal.
const WANTED_FILE = /\.(part\d+\.rar|rar|r\d{2,3}|7z(\.\d{3})?|zip|z\d{2}|\d{3}|pkg|exfat|ffpkg|ffpfsc?|ufs)$/i;
const PORTS = [8777, 8778, 8779];
async function settings() {
return chrome.storage.local.get({ enabled: true, captured: 0 });
}
async function findApp() {
for (const port of PORTS) {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" });
if (r.ok) {
const j = await r.json();
if (j.app === "PS5 Directo") return { port, version: j.version };
}
} catch (e) { /* no esta en ese puerto */ }
}
return null;
}
function fileName(item) {
const f = (item.filename || "").split(/[\\/]/).pop();
if (f) return f;
try { return decodeURIComponent(new URL(item.finalUrl || item.url).pathname.split("/").pop()); }
catch (e) { return ""; }
}
async function handle(item) {
const cfg = await settings();
if (!cfg.enabled) return;
const url = item.finalUrl || item.url;
if (!/^https?:/i.test(url)) return;
const name = fileName(item);
const path = url.split("?")[0];
if (!WANTED_FILE.test(name) && !WANTED_FILE.test(path)) return; // no es un archivo que nos interese
const app = await findApp();
if (!app) { // PS5 Directo cerrado: no tocar
chrome.action.setBadgeText({ text: "off" });
return;
}
try {
const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, original: item.url, name })
});
if (!r.ok) return;
} catch (e) { return; }
try { await chrome.downloads.cancel(item.id); } catch (e) {}
try { await chrome.downloads.erase({ id: item.id }); } catch (e) {}
const n = (cfg.captured || 0) + 1;
await chrome.storage.local.set({ captured: n, last: name });
chrome.action.setBadgeBackgroundColor({ color: "#2563eb" });
chrome.action.setBadgeText({ text: String(n % 1000) });
chrome.notifications.create({
type: "basic", iconUrl: "icon128.png", title: "Enviado a PS5 Directo",
message: (name || "Enlace") + " — no se descarga en el PC."
});
}
// Cuando ya se sabe el nombre del archivo y el enlace final (tras las redirecciones)
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
suggest(); // no cambiamos el nombre
handle(item);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"manifest_version": 3,
"name": "PS5 Directo - Capturar enlaces",
"version": "1.0.0",
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace a PS5 Directo en vez de bajarlo al PC.",
"permissions": ["downloads", "storage", "notifications"],
"host_permissions": ["http://127.0.0.1/*"],
"background": { "service_worker": "bg.js" },
"action": { "default_popup": "popup.html", "default_title": "PS5 Directo", "default_icon": { "16": "icon16.png", "32": "icon32.png" } },
"icons": { "16": "icon16.png", "48": "icon48.png", "128": "icon128.png" }
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="es"><head><meta charset="utf-8">
<style>
body{font:14px system-ui,sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:14px;width:280px}
h1{font-size:15px;margin:0 0 10px}
.row{display:flex;align-items:center;justify-content:space-between;margin:8px 0}
.st{font-size:13px;opacity:.85}
.ok{color:#4ade80}.bad{color:#f87171}
small{opacity:.6;display:block;margin-top:10px;line-height:1.4}
button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6px;padding:4px 10px;cursor:pointer}
</style></head>
<body>
<h1>PS5 Directo</h1>
<div class="row"><label for="en">Capturar enlaces</label><input type="checkbox" id="en"></div>
<div class="st" id="st">Comprobando…</div>
<div class="st" id="cnt"></div>
<div class="row"><span></span><button id="reset">Poner contador a 0</button></div>
<small>Pasa el captcha y pulsa Descargar como siempre: si es un .rar, .7z, .zip, .pkg… y PS5 Directo está abierto, el enlace va a su cuadro de enlaces y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.</small>
<script src="popup.js"></script>
</body></html>
+26
View File
@@ -0,0 +1,26 @@
async function findApp() {
for (const port of [8777, 8778, 8779]) {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" });
if (r.ok) { const j = await r.json(); if (j.app === "PS5 Directo") return j; }
} catch (e) {}
}
return null;
}
(async () => {
const cfg = await chrome.storage.local.get({ enabled: true, captured: 0, last: "" });
const en = document.getElementById("en");
en.checked = cfg.enabled;
en.onchange = () => chrome.storage.local.set({ enabled: en.checked });
document.getElementById("cnt").textContent =
cfg.captured ? `Enviados: ${cfg.captured}${cfg.last ? " (último: " + cfg.last + ")" : ""}` : "";
document.getElementById("reset").onclick = async () => {
await chrome.storage.local.set({ captured: 0, last: "" });
chrome.action.setBadgeText({ text: "" });
document.getElementById("cnt").textContent = "";
};
const app = await findApp();
const st = document.getElementById("st");
if (app) { st.innerHTML = `<span class="ok">● Conectado</span> a PS5 Directo v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
else st.innerHTML = '<span class="bad">● PS5 Directo no está abierto</span> (las descargas seguirán normales)';
})();
+626
View File
@@ -0,0 +1,626 @@
<!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>
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="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="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="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(#bg)"/>
<rect x="8" y="8" width="240" height="240" rx="58" fill="url(#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"/>
<!-- estelas de velocidad -->
<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>
<!-- flecha -->
<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(#arrow)"/>
<!-- bandeja / consola -->
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB