Generado: 2026-10-05 00:15:54 | Hash: 94614aed | PID: 21368
Para LLMs
INSTRUCCIONES GENERALES
1.- HABLAMOS EN CASTELLANO POR DEFECTO.
2.-
Si el usuario sólo te ha enviado el link y es su primer mensaje en ese chat,
probablemente lo haya mandado sin el contenido de los archivos. ES LA IDEA.
La idea es que leas tanto estas instrucciones como el tree del proyecto
para tener el contexto inicial, y en mensajes posteriores te irá enviando
el link con "?inline" y limitaciones de contenido, para enviarte archivos
en texto plano y así evitar que tu parser te lo corte a la mitad, o malgastarte
contexto repitiendo este mensaje y el tree.
(Los parámetros de URL que el user tiene para limitar lo que te envía están más abajo en este mensaje).
SI NECESITAS RECIBIR CUALQUIER ARCHIVO, O EL TREE O ESTE MENSAJE DE NUEVO, PÍDESELO AL USER.
3.-
Si existe algún summary en "summaries/", léelo para tener más contexto
del proyecto sobre el que el usuario te va a dar instrucciones futuras.
CADA SUMMARY TENDRÁ EL NOMBRE DEL LLM QUE LO GENERÓ, Y PODRÍA ESTAR DESACTUALIZADO (porque
el usuario haya avanzado en el proyecto y no haya actualizado el summary). Pero servirá de contexto aun así.
Si no existe ningún summary, no pasa nada.
CONFIRMA LA RECEPCIÓN DEL CONTEXTO DE FORMA CONCISA, Y DALE AL CHAT NOMBRE CON EL SIGUIENTE FORMATO:
(Fecha YYYY-MM-DD)_(Nombre de la carpeta raíz del proyecto, que puedes ver al inicio del tree)
EL USUARIO TE DARÁ MÁS INSTRUCCIONES EN BREVE.
PARÁMETROS DE URL DISPONIBLES
Además del filtrado que ya aplica el servidor al arrancar, el LLM
puede controlar qué ve usando parámetros en la URL. Se pueden
combinar varios, y algunos se pueden repetir.
- ?f=archivo.ext -> incluir un archivo concreto (repetible).
- ?x=directorio/archivo -> excluir del contenido.
- ?i=directorio/archivo -> incluir en el contenido.
- ?tx=directorio/archivo -> excluir del tree (no del contenido).
- ?ext=.extension -> mostrar solo esas extensiones (repetible).
- ?tree (=0) -> no mostrar el tree.
- ?content (=0) -> no mostrar contenido de archivos.
- ?inline (=1) -> servir los archivos como texto plano, sin parsear HTML.
- ?llm (=0) -> ocultar este mensaje inicial.
Ejemplo: ?ext=.html&ext=.md&tree=0&inline
SOBRE LOS RESÚMENES DE PROGRESO
Esta sección describe CÓMO debes redactar un resumen del
progreso del proyecto SI el usuario te lo pide. NO es una
orden de generar un resumen por iniciativa propia.
REGLA CRÍTICA: NO generes ningún resumen de progreso salvo
que el usuario te lo pida EXPLÍCITAMENTE en el mensaje actual.
Se considera petición explícita si el usuario usa palabras
como: "resumen", "resume", "hazme un resumen", "resumen del
progreso", "summary", "actualiza el resumen", "genera un
resumen". Si no aparecen esas palabras (o equivalentes claros)
en el mensaje del usuario, NO generes resumen: responde
únicamente a lo que te haya pedido.
Cuando SÍ te pida un resumen, tu respuesta debe cumplir estas
reglas sin excepción:
1. El mensaje completo es el resumen. No escribas nada antes del
resumen (ni "Aquí tienes", ni "Claro", ni una introducción) ni nada
después (ni preguntas, ni ofrecimientos, ni despedidas). El primer
carácter de tu respuesta debe ser el primer carácter del resumen.
2. Formato markdown nativo. Escribe directamente en markdown
(encabezados con #, listas con -, negritas con **). No envuelvas
todo en un bloque de código con ```. El resumen debe poder copiarse
entero y pegarse tal cual en un archivo .md.
3. Estructura obligatoria del resumen:
- Un título de nivel 1 con el nombre del proyecto.
- Una sección de contexto (qué es el proyecto, de dónde viene el chat).
- Una sección de features actuales (todo lo que ya funciona).
- Una sección de features restantes, organizadas en bloques
numerados en orden cronológicamente coherente de implementación.
4. No pidas confirmación ni ofrezcas ajustes. El resumen es la
respuesta completa. Si algo falta, el usuario te lo pedirá después
en un mensaje aparte.
Árbol mobile_Games_Platform/
├── apps
│ ├── core-showcase
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ ├── dice
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ ├── gamepad-tester
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ ├── maps
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ ├── paint
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ ├── plantilla
│ │ ├── client
│ │ │ ├── mobile
│ │ │ │ ├── mobile.css
│ │ │ │ └── mobile.html
│ │ │ ├── tv
│ │ │ │ ├── tv.css
│ │ │ │ └── tv.html
│ │ │ ├── mobile.js
│ │ │ └── tv.js
│ │ ├── app.json
│ │ └── server.js
│ └── sensor-tester
│ ├── client
│ │ ├── mobile
│ │ │ ├── mobile.css
│ │ │ └── mobile.html
│ │ ├── tv
│ │ │ ├── tv.css
│ │ │ └── tv.html
│ │ ├── mobile.js
│ │ └── tv.js
│ ├── app.json
│ └── server.js
├── bin
│ └── cloudflared.exe
├── public
│ ├── assets
│ │ ├── ammo
│ │ │ └── ammo.wasm.wasm
│ │ ├── models
│ │ │ └── default.json
│ │ └── themes
│ │ └── default
│ │ ├── diffuse-dark.png
│ │ ├── diffuse-light.png
│ │ ├── normal.png
│ │ └── specular.jpg
│ ├── control.html
│ ├── gamepad.js
│ ├── mobile.html
│ ├── sdk.js
│ ├── sensors.js
│ └── tv.html
├── summaries
│ └── deepseek.md
├── ai_prompt_compiler.py
├── app-layout-store.js
├── app-profile-store.js
├── app-runtime-worker.js
├── app-runtime.js
├── event-store.js
├── external-apps.json
├── ideas.md
├── package-lock.json
├── package.json
├── player-store.js
├── profile-store.js
├── profiles.json
├── serve.ps1
├── server.js
├── state-store.js
└── TODO.md
Contenido (76 archivos)
Abrir todo
Cerrar todo
.\external-apps.json 3 caracteres []
.\apps\maps\app.json 143 caracteres {
"name": "Maps",
"icon": "🗺️",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js"
} .\apps\dice\app.json 145 caracteres {
"name": "3D Dice",
"icon": "🎲",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js"
} .\apps\plantilla\app.json 145 caracteres {
"name": "Counter",
"icon": "🎮",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js"
} .\apps\paint\app.json 151 caracteres {
"name": "Collab Paint",
"icon": "🎨",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js"
}
.\apps\sensor-tester\client\tv\tv.html 167 caracteres <div class="sensor-tester-tv">
<div class="toolbar">
<div class="toolbar-title">📡 Sensor Tester</div>
</div>
<div class="content" id="content"></div>
</div> .\apps\gamepad-tester\client\tv\tv.html 169 caracteres <div class="gamepad-tester-tv">
<div class="toolbar">
<div class="toolbar-title">🎮 Gamepad Tester</div>
</div>
<div class="content" id="content"></div>
</div> .\apps\core-showcase\app.json 172 caracteres {
"name": "Core Showcase",
"icon": "🧪",
"apiVersion": 1,
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js"
} .\apps\gamepad-tester\app.json 189 caracteres {
"name": "Gamepad Tester",
"icon": "🎮",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js",
"gamepad": {
"tv": true,
"mobile": true
}
} .\apps\sensor-tester\app.json 189 caracteres {
"name": "Sensor Tester",
"icon": "📡",
"client": {
"tv": "tv.js",
"mobile": "mobile.js"
},
"server": "server.js",
"sensors": {
"tv": false,
"mobile": true
}
} .\apps\sensor-tester\client\mobile\mobile.html 222 caracteres <div class="sensor-tester-mobile">
<div class="toolbar">
<div class="toolbar-title">📡 Sensor Tester</div>
</div>
<div class="content">
<div id="status"></div>
<div id="sensor-list"></div>
</div>
</div> .\apps\gamepad-tester\client\mobile\mobile.html 257 caracteres <div class="gamepad-tester-mobile">
<div class="toolbar">
<div class="toolbar-title">🎮 Gamepad Tester</div>
</div>
<div class="content">
<div id="status"></div>
<div id="gamepad-list"></div>
</div>
<div id="server-view"></div>
</div> .\apps\plantilla\client\tv\tv.html 321 caracteres <div class="tv">
<div class="toolbar">TOP BAR</div>
<div class="content">
<div style="height:100%;display:flex;align-items:center;justify-content:center;">
<span id="count" style="font-size:96px;color:#a78bfa;">0</span>
</div>
</div>
<div class="footer">BOTTOM BAR</div>
</div> .\apps\maps\client\tv\tv.html 356 caracteres <div class="tv">
<div class="toolbar" style="display:none;"></div>
<div class="content" id="contentContainer">
<div id="map-wrapper">
<div id="map"></div>
</div>
</div>
<div class="footer">
<span id="lastAction" style="font-size:11px; color:rgba(255,255,255,0.3);">Loading map...</span>
</div>
</div> .\apps\paint\client\tv\tv.html 389 caracteres <div class="paint-tv">
<div class="canvas-wrapper" id="canvasWrapper">
<div class="grid"></div>
<canvas id="canvas"></canvas>
</div>
<div class="footer">
<span class="zoom-label" id="zoomLabel">100%</span>
<button id="zoomOutBtn">➖</button>
<button id="zoomResetBtn">⟲</button>
<button id="zoomInBtn">➕</button>
</div>
</div> .\package.json 405 caracteres {
"name": "mobile-games-platform",
"version": "1.0.0",
"private": true,
"description": "Plataforma de mando (movil) + TV para jugar varias apps/juegos en la misma sala",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@3d-dice/dice-box": "^1.0.8",
"dotenv": "^18.0.3",
"express": "^5.2.1",
"sqlite3": "^6.0.1",
"ws": "^8.21.3"
}
}
.\apps\plantilla\client\mobile\mobile.html 406 caracteres <div class="mobile">
<div class="toolbar">TOP BAR</div>
<div class="content">
<div style="flex:1;overflow:auto;padding:20px;">
<button id="incBtn" style="width:100%;padding:24px;font-size:18px;font-weight:600;border-radius:12px;border:none;background:rgba(167,139,250,0.15);color:#a78bfa;">➕ Add</button>
</div>
</div>
<div class="footer">BOTTOM BAR</div>
</div> .\serve.ps1 432 caracteres #!/bin/bash
serve --tree-exclude node_modules --tree-exclude data -x data -x node_modules -x Out -x public/assets -x TODO.md -x ai_prompt_compiler.py -x package-lock.json -x ideas.md -x bin
# Servimos el proyecto con las carpetas y archivos que no queremos servir ignoradas (además de las de seguridad hardcodeadas). En este proyecto, estos parametros los queremos básicamente siempre. El resto podemos añadirlos aquí o en el Link. .\apps\dice\client\tv\tv.html 532 caracteres <div class="tv">
<div class="toolbar">
<span style="font-size:12px; color:rgba(255,255,255,0.5);">🎲 3D Dice Table</span>
<span id="tableStats" style="font-size:12px; color:rgba(255,255,255,0.4); margin-left:auto;">0 dice</span>
</div>
<div class="content">
<div id="dice-box">
<div id="total-sum">0</div>
</div>
</div>
<div class="footer">
<span id="lastAction" style="font-size:11px; color:rgba(255,255,255,0.3);">Loading 3D engine...</span>
</div>
</div> .\apps\core-showcase\client\tv\tv.html 677 caracteres <div class="showcase-tv">
<div class="showcase-content">
<div class="showcase-header">
<h1>Core Showcase</h1>
<div id="summary">
0 / 8
</div>
</div>
<div id="results">
<div class="empty-state">
Waiting for tests...
</div>
</div>
<div class="runtime">
Runtime:
<strong id="runtimeStatus">
Running
</strong>
</div>
<div class="last-test">
Last test:
<strong id="lastTest">
—
</strong>
</div>
</div>
</div> .\apps\plantilla\client\mobile.js 693 caracteres export async function mount(container, sdk) {
const cssId = 'apptest-mobile-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/plantilla/client/mobile/mobile.css`;
document.head.appendChild(link);
}
const response = await fetch(
`/apps/plantilla/client/mobile/mobile.html`
);
const html = await response.text();
container.innerHTML = html;
container.style.overflow = 'hidden';
container.querySelector('#incBtn').onclick = () => {
sdk.sendApp({
type: 'increment'
});
};
} .\apps\plantilla\client\tv.js 799 caracteres // TV side. Loads HTML template and CSS, then handles app logic.
export async function mount(container, sdk) {
// Load CSS
const cssId = 'apptest-tv-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/plantilla/client/tv/tv.css`;
document.head.appendChild(link);
}
// Load HTML template
const response = await fetch(`/apps/plantilla/client/tv/tv.html`);
const html = await response.text();
container.innerHTML = html;
// Attach event listeners and logic
const countEl = container.querySelector('#count');
return sdk.onApp((data) => {
if (data.type === 'count') countEl.textContent = data.value;
});
} .\apps\core-showcase\client\mobile\mobile.html 1185 caracteres <div class="showcase-mobile">
<div class="showcase-content">
<h1>Core Showcase</h1>
<div class="button-grid">
<button id="runAllBtn">
RUN ALL
</button>
<button id="playersBtn">
Players
</button>
<button id="profileBtn">
Profile
</button>
<button id="saveBtn">
App Save
</button>
<button id="stateBtn">
State
</button>
<button id="eventsBtn">
Events
</button>
<button id="timersBtn">
Timers
</button>
<button id="messagingBtn">
Messaging
</button>
<button id="lifecycleBtn">
Lifecycle
</button>
<button
id="crashBtn"
class="danger"
>
CRASH TEST
</button>
</div>
<div
id="status"
class="status"
>
Waiting for tests...
</div>
</div>
</div> .\state-store.js 1209 caracteres class StateStore {
constructor(initialState = {}) {
this.state = clone(initialState);
this.revision = 0;
}
get() {
return clone(this.state);
}
set(nextState) {
assertSerializable(nextState);
this.state = clone(nextState);
this.revision++;
return this.get();
}
update(updater) {
if (typeof updater !== "function") {
throw new TypeError("StateStore.update() requires a function");
}
const current = this.get();
const nextState = updater(current);
if (nextState === undefined) {
throw new Error("StateStore.update() updater must return the new state");
}
return this.set(nextState);
}
snapshot() {
return {
revision: this.revision,
state: this.get(),
};
}
getRevision() {
return this.revision;
}
reset(initialState = {}) {
return this.set(initialState);
}
}
function clone(value) {
if (value === undefined) return undefined;
return structuredClone(value);
}
function assertSerializable(value) {
try {
structuredClone(value);
} catch (err) {
throw new TypeError(`State must be structured-cloneable: ${err.message}`);
}
}
module.exports = {
StateStore,
};
.\apps\maps\client\tv\tv.css 1315 caracteres /* ============================================================
MAPS TV - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire TV screen */
.tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1c2e;
}
/* Hide toolbar (not needed for maps) */
.tv .toolbar {
display: none !important;
}
/* Content area containing the map */
.tv .content {
flex: 1;
overflow: hidden;
position: relative;
}
/* Map wrapper - centers the map and allows rotation */
#map-wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform-origin: center center;
}
/* The actual map container */
#map {
width: 100%;
height: 100%;
}
/* Hide Leaflet attribution (not needed) */
.leaflet-control-attribution {
display: none !important;
}
/* ============================================================
BOTTOM FOOTER
============================================================ */
.tv .footer {
padding: 4px 14px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
gap: 6px;
height: 28px;
} .\apps\core-showcase\client\mobile\mobile.css 1341 caracteres .showcase-mobile {
width: 100%;
height: 100%;
overflow: hidden;
color: #e0e0e0;
font-family:
'Segoe UI',
sans-serif;
}
.showcase-content {
width: 100%;
height: 100%;
padding: 20px;
overflow: auto;
}
.showcase-content h1 {
margin: 0 0 20px;
font-size: 24px;
}
.button-grid {
display: grid;
grid-template-columns:
repeat(
2,
minmax(0, 1fr)
);
gap: 10px;
}
.button-grid button {
width: 100%;
padding: 16px;
border: none;
border-radius: 10px;
background:
rgba(
167,
139,
250,
0.15
);
color: #a78bfa;
font-size: 15px;
font-weight: 600;
}
.button-grid button:first-child {
grid-column: 1 / -1;
background:
rgba(
167,
139,
250,
0.25
);
}
.button-grid .danger {
grid-column: 1 / -1;
background:
rgba(
220,
38,
38,
0.2
);
color: #f87171;
}
.status {
margin-top: 20px;
padding: 14px;
border-radius: 10px;
background:
rgba(
255,
255,
255,
0.05
);
color: #aaa;
text-align: center;
} .\apps\dice\client\tv\tv.css 1599 caracteres /* ============================================================
DICE TV - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire TV screen */
.tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1c2e;
}
/* Top toolbar */
.tv .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none;
min-height: 34px;
}
/* Content area */
.tv .content {
flex: 1;
overflow: hidden;
position: relative;
}
/* Bottom footer */
.tv .footer {
padding: 4px 14px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
gap: 6px;
height: 28px;
}
/* ============================================================
DICE BOX CONTAINER
============================================================ */
#dice-box {
width: 100%;
height: 100%;
position: relative;
}
/* Dice box canvas */
#dice-box canvas {
width: 100%;
height: 100%;
display: block;
}
/* Total sum display */
#total-sum {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
font-size: 48px;
font-weight: bold;
color: #ffd700;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
pointer-events: none;
z-index: 10;
} .\profiles.json 1646 caracteres [
{
"id": "246930c3-7ab5-4608-ad77-5ab8b5837867",
"name": "TV",
"created": "2026-09-07T16:41:51.240Z",
"lastSeen": "2026-09-07T16:41:51.240Z",
"apps": {}
},
{
"id": "bf3350cf-1a43-45a3-8dd4-872f4c3d8a78",
"name": "Player",
"created": "2026-09-07T16:41:56.776Z",
"lastSeen": "2026-09-07T16:41:56.776Z",
"apps": {}
},
{
"id": "fdd8c3c4-765e-441d-a3e4-52ee86731eb1",
"name": "Player",
"created": "2026-09-07T16:41:57.035Z",
"lastSeen": "2026-09-07T16:41:57.035Z",
"apps": {}
},
{
"id": "02bc366c-8eb1-4d9e-b1d8-6055864e3f2d",
"name": "TV",
"created": "2026-09-07T17:11:01.954Z",
"lastSeen": "2026-09-07T17:11:01.954Z",
"apps": {}
},
{
"id": "be4efeae-c8ee-40f1-83cd-e456a041680b",
"name": "Player",
"created": "2026-09-07T17:11:03.835Z",
"lastSeen": "2026-09-07T17:11:03.835Z",
"apps": {}
},
{
"id": "63adf97b-bbb3-4a16-a436-23e78efb5e47",
"name": "Player",
"created": "2026-09-07T17:13:39.117Z",
"lastSeen": "2026-09-07T17:13:39.117Z",
"apps": {}
},
{
"id": "68c1b2ff-3482-4d8c-97b7-16c4bf170979",
"name": "Player",
"created": "2026-09-07T17:13:44.795Z",
"lastSeen": "2026-09-07T17:13:44.795Z",
"apps": {}
},
{
"id": "f2ac556e-41ae-4a6d-8f25-6483a91d567f",
"name": "TV",
"created": "2026-09-07T17:13:45.087Z",
"lastSeen": "2026-09-07T17:13:45.087Z",
"apps": {}
}
] .\apps\plantilla\client\mobile\mobile.css 1661 caracteres /* ============================================================
MOBILE APP LAYOUT
============================================================ */
/* Main container - fills the entire phone screen */
.mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
/* ============================================================
TOP TOOLBAR
============================================================ */
.mobile .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0; /* Prevent toolbar from shrinking */
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none; /* Prevent touch gestures */
min-height: 34px;
}
/* ============================================================
CONTENT AREA
============================================================ */
.mobile .content {
flex: 1; /* Take up all remaining space */
overflow: hidden;
position: relative;
touch-action: none; /* Prevent touch gestures */
}
/* ============================================================
BOTTOM FOOTER
============================================================ */
.mobile .footer {
padding: 4px 10px;
background: rgba(0, 0, 0, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0; /* Prevent footer from shrinking */
border-top: 1px solid rgba(255, 255, 255, 0.05);
touch-action: none; /* Prevent touch gestures */
min-height: 32px;
} .\apps\sensor-tester\server.js 1759 caracteres module.exports = function createApp(ctx) {
const lastSnapshotByPlayer = new Map();
return {
async onStart() {
ctx.log("Sensor Tester started");
ctx.broadcast({ type: "snapshots", snapshots: [] });
},
async onMessage(data, player) {
if (data.type !== "sensor_snapshot") return;
lastSnapshotByPlayer.set(player.id, {
playerId: player.id,
playerName: player.name,
playerRole: player.role,
sensors: data.sensors || null,
timestamp: Date.now(),
});
broadcastSnapshots();
},
async onPlayerJoined(player) {
ctx.log(`Player joined: ${player.name} (${player.id})`);
},
async onPlayerConnected(player) {
ctx.log(`Player connected: ${player.name} (${player.id})`);
ctx.sendTo(player.id, {
type: "snapshots",
snapshots: getSnapshotsArray(),
});
},
async onPlayerDisconnected(player) {
ctx.log(`Player disconnected: ${player.name} (${player.id})`);
lastSnapshotByPlayer.delete(player.id);
broadcastSnapshots();
},
async onPlayerLeft(player) {
ctx.log(`Player left: ${player.name} (${player.id})`);
lastSnapshotByPlayer.delete(player.id);
broadcastSnapshots();
},
async onStop() {
ctx.log("Sensor Tester stopped");
},
};
function getSnapshotsArray() {
const now = Date.now();
for (const [playerId, snapshot] of lastSnapshotByPlayer) {
if (now - snapshot.timestamp > 5000) {
lastSnapshotByPlayer.delete(playerId);
}
}
return Array.from(lastSnapshotByPlayer.values());
}
function broadcastSnapshots() {
ctx.broadcast({
type: "snapshots",
snapshots: getSnapshotsArray(),
});
}
}; .\apps\plantilla\client\tv\tv.css 1875 caracteres /* ============================================================
TV APP LAYOUT
============================================================ */
/* Main container - fills the entire TV screen */
.tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1c2e;
}
/* ============================================================
TOP TOOLBAR
============================================================ */
.tv .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0; /* Prevent toolbar from shrinking */
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none; /* Prevent touch gestures */
min-height: 34px;
}
/* ============================================================
CONTENT AREA
============================================================ */
.tv .content {
flex: 1; /* Take up all remaining space */
overflow: hidden;
position: relative;
}
/* ============================================================
BOTTOM FOOTER
============================================================ */
.tv .footer {
padding: 4px 14px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0; /* Prevent footer from shrinking */
border-top: 1px solid rgba(255, 255, 255, 0.05);
gap: 6px;
height: 28px;
}
/* Footer buttons */
.tv .footer button {
padding: 2px 10px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
font-size: 11px;
}
/* Footer button hover effect */
.tv .footer button:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
} .\app-profile-store.js 2019 caracteres const fs = require("fs");
const path = require("path");
class AppProfileStore {
constructor(options = {}) {
this.baseDir = path.resolve(
options.baseDir || path.join(__dirname, "data", "players"),
);
}
get(profileId, appId) {
const filePath = this.getPath(profileId, appId);
if (!fs.existsSync(filePath)) {
return null;
}
try {
return clone(JSON.parse(fs.readFileSync(filePath, "utf8")));
} catch (err) {
throw new Error(
`Could not read app data "${appId}" for profile "${profileId}": ${err.message}`,
);
}
}
set(profileId, appId, data) {
assertSerializable(data);
const filePath = this.getPath(profileId, appId);
atomicWrite(filePath, data);
return clone(data);
}
delete(profileId, appId) {
const filePath = this.getPath(profileId, appId);
if (!fs.existsSync(filePath)) {
return false;
}
fs.unlinkSync(filePath);
return true;
}
getPath(profileId, appId) {
validateId(profileId, "profileId");
validateId(appId, "appId");
return path.join(this.baseDir, profileId, "apps", `${appId}.json`);
}
}
function validateId(value, name) {
if (
typeof value !== "string" ||
!value.trim() ||
value.includes("/") ||
value.includes("\\") ||
value.includes("..")
) {
throw new TypeError(`Invalid ${name}`);
}
}
function assertSerializable(value) {
try {
structuredClone(value);
} catch (err) {
throw new TypeError(`Data must be structured-cloneable: ${err.message}`);
}
}
function clone(value) {
if (value === undefined) return undefined;
return structuredClone(value);
}
function atomicWrite(filePath, data) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(temporaryPath, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(temporaryPath, filePath);
}
module.exports = {
AppProfileStore,
};
.\apps\plantilla\server.js 2061 caracteres module.exports = function createApp(ctx) {
return {
async onStart() {
ctx.state.set({
counter: 0
});
ctx.broadcast({
type: 'count',
value: 0
});
},
async onMessage(data, player) {
if (data.type === 'increment') {
ctx.state.update(state => ({
...state,
counter: state.counter + 1
}));
ctx.events.emit('COUNTER_INCREMENTED', {
playerId: player.id
});
ctx.broadcast({
type: 'count',
value: ctx.state.get().counter
});
return;
}
if (data.type === 'reset') {
ctx.state.set({
counter: 0
});
ctx.events.emit('COUNTER_RESET', {
playerId: player.id
});
ctx.broadcast({
type: 'count',
value: 0
});
}
},
async onPlayerJoined(player) {
ctx.log(
`Player joined: ${player.name} (${player.id})`
);
},
async onPlayerConnected(player) {
ctx.log(
`Player connected: ${player.name} (${player.id})`
);
ctx.sendTo(
player.id,
{
type: 'count',
value: ctx.state.get().counter
}
);
},
async onPlayerDisconnected(player) {
ctx.log(
`Player disconnected: ${player.name} (${player.id})`
);
},
async onPlayerLeft(player) {
ctx.log(
`Player left: ${player.name} (${player.id})`
);
},
async onStop() {
ctx.log('Counter app stopped');
}
};
}; .\apps\gamepad-tester\server.js 2194 caracteres module.exports = function createApp(ctx) {
const lastSnapshotByPlayer = new Map();
return {
async onStart() {
ctx.log("Gamepad Tester started");
ctx.broadcast({
type: "snapshots",
snapshots: [],
});
},
async onMessage(data, player) {
if (data.type !== "gamepad_snapshot") return;
lastSnapshotByPlayer.set(player.id, {
playerId: player.id,
playerName: player.name,
playerRole: player.role,
gamepads: data.gamepads || [],
timestamp: Date.now(),
});
broadcastSnapshots();
},
async onPlayerJoined(player) {
ctx.log(`Player joined: ${player.name} (${player.id})`);
},
async onPlayerConnected(player) {
ctx.log(`Player connected: ${player.name} (${player.id})`);
ctx.sendTo(player.id, {
type: "snapshots",
snapshots: getSnapshotsArray(),
});
},
async onPlayerDisconnected(player) {
ctx.log(`Player disconnected: ${player.name} (${player.id})`);
lastSnapshotByPlayer.delete(player.id);
broadcastSnapshots();
},
async onPlayerLeft(player) {
ctx.log(`Player left: ${player.name} (${player.id})`);
lastSnapshotByPlayer.delete(player.id);
broadcastSnapshots();
},
async onStop() {
ctx.log("Gamepad Tester stopped");
},
};
// ----------------------------------------------------------
// El timeout es alto (10s) para no borrar snapshots por
// microcortes de red. Como el SDK solo envía cuando hay
// cambios (o cada 80ms si los hay), un mando en reposo
// no spamea, pero si no se envía nada durante 10s es que
// el cliente se ha desconectado.
// ----------------------------------------------------------
function getSnapshotsArray() {
const now = Date.now();
for (const [playerId, snapshot] of lastSnapshotByPlayer) {
if (now - snapshot.timestamp > 10000) {
lastSnapshotByPlayer.delete(playerId);
}
}
return Array.from(lastSnapshotByPlayer.values());
}
function broadcastSnapshots() {
ctx.broadcast({
type: "snapshots",
snapshots: getSnapshotsArray(),
});
}
}; .\apps\paint\client\tv\tv.css 2215 caracteres /* ============================================================
PAINT TV - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire TV screen */
.paint-tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #181818;
}
/* ============================================================
CANVAS AREA
============================================================ */
.paint-tv .canvas-wrapper {
flex: 1;
overflow: hidden;
position: relative;
background: #181818;
touch-action: none;
}
/* Background grid pattern */
.paint-tv .canvas-wrapper .grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
z-index: 0;
}
/* The actual drawing canvas */
.paint-tv canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
background: #ffffff;
display: block;
touch-action: none;
z-index: 1;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.4);
image-rendering: pixelated;
pointer-events: none;
}
/* ============================================================
BOTTOM FOOTER - ZOOM CONTROLS
============================================================ */
.paint-tv .footer {
padding: 4px 14px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
gap: 6px;
height: 28px;
}
/* Zoom label showing current percentage */
.paint-tv .zoom-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.15);
min-width: 35px;
text-align: center;
}
/* Zoom control buttons */
.paint-tv .footer button {
padding: 2px 10px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
font-size: 11px;
}
/* Button hover effect */
.paint-tv .footer button:hover {
background: rgba(255, 255, 255, 0.12);
color: #e0e0e0;
} .\apps\core-showcase\client\tv\tv.css 2222 caracteres .showcase-tv {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1c2e;
color: #e0e0e0;
font-family:
'Segoe UI',
sans-serif;
}
.showcase-content {
width: 100%;
height: 100%;
padding: 30px;
overflow: auto;
}
.showcase-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.showcase-header h1 {
margin: 0;
font-size: 32px;
}
#summary {
padding: 10px 18px;
border-radius: 10px;
background:
rgba(
167,
139,
250,
0.15
);
color: #a78bfa;
font-size: 20px;
font-weight: 700;
}
#results {
display: grid;
grid-template-columns:
repeat(
4,
minmax(0, 1fr)
);
gap: 14px;
}
.result {
min-height: 120px;
padding: 18px;
border:
1px solid
rgba(
255,
255,
255,
0.08
);
border-radius: 12px;
background:
rgba(
0,
0,
0,
0.18
);
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.result-name {
font-size: 18px;
font-weight: 600;
}
.result-status {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.result.pass .result-status {
color: #4ade80;
}
.result.fail .result-status {
color: #f87171;
}
.result.waiting .result-status {
color: #aaa;
}
.result-message {
color: #aaa;
font-size: 14px;
line-height: 1.4;
}
.empty-state {
grid-column: 1 / -1;
padding: 50px;
border-radius: 12px;
background:
rgba(
0,
0,
0,
0.18
);
color: #777;
text-align: center;
font-size: 20px;
}
.runtime,
.last-test {
margin-top: 14px;
padding: 12px 16px;
border-radius: 8px;
background:
rgba(
0,
0,
0,
0.15
);
color: #888;
}
.runtime strong,
.last-test strong {
color: #ddd;
} .\event-store.js 2344 caracteres class EventStore {
constructor() {
this.events = [];
this.nextSequence = 1;
this.listeners = new Map();
}
emit(type, payload = {}) {
if (typeof type !== "string" || !type.trim()) {
throw new TypeError("EventStore.emit() requires a non-empty event type");
}
assertSerializable(payload);
const event = {
sequence: this.nextSequence++,
type,
payload: clone(payload),
timestamp: Date.now(),
};
this.events.push(event);
const listeners = this.listeners.get(type) || [];
for (const listener of [...listeners]) {
try {
listener(clone(event));
} catch (err) {
console.error(`[EventStore] Error in listener for "${type}":`, err);
}
}
return clone(event);
}
on(type, listener) {
if (typeof type !== "string" || !type.trim()) {
throw new TypeError("EventStore.on() requires a non-empty event type");
}
if (typeof listener !== "function") {
throw new TypeError("EventStore.on() requires a function");
}
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
const listeners = this.listeners.get(type);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(type);
}
};
}
history(options = {}) {
const { fromSequence = 1, toSequence = Infinity, type = null } = options;
return this.events
.filter((event) => {
if (event.sequence < fromSequence) return false;
if (event.sequence > toSequence) return false;
if (type !== null && event.type !== type) return false;
return true;
})
.map(clone);
}
get length() {
return this.events.length;
}
last() {
if (this.events.length === 0) {
return null;
}
return clone(this.events[this.events.length - 1]);
}
clear() {
this.events = [];
this.nextSequence = 1;
}
}
function clone(value) {
if (value === undefined) return undefined;
return structuredClone(value);
}
function assertSerializable(value) {
try {
structuredClone(value);
} catch (err) {
throw new TypeError(
`Event payload must be structured-cloneable: ${err.message}`,
);
}
}
module.exports = {
EventStore,
};
.\apps\core-showcase\client\mobile.js 2719 caracteres export async function mount(container, sdk) {
const cssId =
'core-showcase-mobile-style';
if (!document.getElementById(cssId)) {
const link =
document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href =
'/apps/core-showcase/client/mobile/mobile.css';
document.head.appendChild(link);
}
const response =
await fetch(
'/apps/core-showcase/client/mobile/mobile.html'
);
if (!response.ok) {
throw new Error(
`Could not load mobile.html: ${response.status}`
);
}
const html =
await response.text();
container.innerHTML = html;
container.style.overflow =
'hidden';
function send(type) {
sdk.sendApp({
type
});
}
container
.querySelector('#runAllBtn')
.onclick = () => {
send('run_all');
};
container
.querySelector('#playersBtn')
.onclick = () => {
send('test_players');
};
container
.querySelector('#profileBtn')
.onclick = () => {
send('test_profile');
};
container
.querySelector('#saveBtn')
.onclick = () => {
send('test_save');
};
container
.querySelector('#stateBtn')
.onclick = () => {
send('test_state');
};
container
.querySelector('#eventsBtn')
.onclick = () => {
send('test_events');
};
container
.querySelector('#timersBtn')
.onclick = () => {
send('test_timers');
};
container
.querySelector('#messagingBtn')
.onclick = () => {
send('test_messaging');
};
container
.querySelector('#lifecycleBtn')
.onclick = () => {
send('test_lifecycle');
};
container
.querySelector('#crashBtn')
.onclick = () => {
send('crash');
};
sdk.onApp(data => {
if (!data) {
return;
}
const status =
container.querySelector('#status');
if (
data.type ===
'showcase_update'
) {
if (status) {
status.textContent =
`${data.summary.passed} / ` +
`${data.summary.total} passed`;
}
}
if (
data.type ===
'showcase_lifecycle'
) {
if (status) {
status.textContent =
data.message;
}
}
});
} .\apps\core-showcase\client\tv.js 3263 caracteres export async function mount(container, sdk) {
const cssId =
'core-showcase-tv-style';
if (!document.getElementById(cssId)) {
const link =
document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href =
'/apps/core-showcase/client/tv/tv.css';
document.head.appendChild(link);
}
const response =
await fetch(
'/apps/core-showcase/client/tv/tv.html'
);
if (!response.ok) {
throw new Error(
`Could not load tv.html: ${response.status}`
);
}
const html =
await response.text();
container.innerHTML = html;
container.style.overflow =
'hidden';
const results =
container.querySelector(
'#results'
);
const summary =
container.querySelector(
'#summary'
);
const runtimeStatus =
container.querySelector(
'#runtimeStatus'
);
const lastTest =
container.querySelector(
'#lastTest'
);
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function renderResults(data) {
if (
!data ||
!data.results ||
!results
) {
return;
}
results.innerHTML = '';
for (
const [name, result]
of Object.entries(data.results)
) {
const element =
document.createElement('div');
element.className =
`result ${result.status}`;
element.innerHTML = `
<div class="result-header">
<span class="result-name">
${escapeHtml(name)}
</span>
<span class="result-status">
${escapeHtml(result.status)}
</span>
</div>
<div class="result-message">
${escapeHtml(
result.message || ''
)}
</div>
`;
results.appendChild(element);
}
if (
data.summary &&
summary
) {
summary.textContent =
`${data.summary.passed} / ` +
`${data.summary.total}`;
}
if (
data.summary &&
lastTest
) {
lastTest.textContent =
`${data.summary.passed} / ` +
`${data.summary.total}`;
}
}
sdk.onApp(data => {
if (!data) {
return;
}
if (
data.type ===
'showcase_update'
) {
renderResults(data);
}
if (
data.type ===
'showcase_lifecycle'
) {
if (runtimeStatus) {
runtimeStatus.textContent =
data.message;
}
}
});
} .\apps\sensor-tester\client\tv\tv.css 3588 caracteres .sensor-tester-tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #0e0f18;
color: #e0e0e0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.sensor-tester-tv .toolbar {
flex: 0 0 auto;
padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.25);
}
.sensor-tester-tv .toolbar-title {
font-size: 20px;
font-weight: 600;
}
.sensor-tester-tv .content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
gap: 20px;
align-content: start;
}
.sensor-tester-tv .empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 24px;
color: rgba(255, 255, 255, 0.4);
}
.sensor-tester-tv .empty-icon {
font-size: 72px;
margin-bottom: 16px;
opacity: 0.5;
}
.sensor-tester-tv .empty-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.7);
}
.sensor-tester-tv .empty-hint {
font-size: 15px;
max-width: 500px;
line-height: 1.5;
}
.sensor-tester-tv .player-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 18px;
}
.sensor-tester-tv .player-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sensor-tester-tv .player-role {
font-size: 22px;
}
.sensor-tester-tv .player-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 600;
}
.sensor-tester-tv .section {
margin-bottom: 14px;
}
.sensor-tester-tv .section:last-child {
margin-bottom: 0;
}
.sensor-tester-tv .section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 8px;
}
.sensor-tester-tv .vector-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.sensor-tester-tv .axis-mini {
display: flex;
align-items: center;
gap: 10px;
}
.sensor-tester-tv .axis-letter {
flex-shrink: 0;
width: 18px;
text-align: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.sensor-tester-tv .axis-track {
flex: 1;
position: relative;
height: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.sensor-tester-tv .axis-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(167, 139, 250, 0.4);
transition: width 0.05s linear;
}
.sensor-tester-tv .axis-center {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.2);
}
.sensor-tester-tv .axis-num {
flex-shrink: 0;
width: 60px;
text-align: right;
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.7);
}
.sensor-tester-tv .orientation-meta {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.sensor-tester-tv .compass {
display: flex;
align-items: center;
gap: 16px;
}
.sensor-tester-tv .compass-arrow {
font-size: 32px;
transition: transform 0.1s linear;
}
.sensor-tester-tv .compass-value {
font-size: 20px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.8);
} .\profile-store.js 3612 caracteres const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
class ProfileStore {
constructor(filePath) {
if (!filePath) {
throw new TypeError("ProfileStore requires a file path");
}
this.filePath = path.resolve(filePath);
this.profiles = new Map();
this.load();
}
load() {
if (!fs.existsSync(this.filePath)) {
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
const data = JSON.parse(raw);
if (!Array.isArray(data)) {
throw new Error("Profiles file must contain an array");
}
for (const profile of data) {
if (
!profile ||
typeof profile !== "object" ||
typeof profile.id !== "string"
) {
continue;
}
this.profiles.set(profile.id, profile);
}
} catch (err) {
throw new Error(`Could not load profiles: ${err.message}`);
}
}
save() {
const directory = path.dirname(this.filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryFile = `${this.filePath}.tmp`;
const data = JSON.stringify([...this.profiles.values()], null, 4);
fs.writeFileSync(temporaryFile, data, "utf8");
fs.renameSync(temporaryFile, this.filePath);
}
create(name = "Player") {
const now = new Date().toISOString();
const profile = {
id: crypto.randomUUID(),
name: typeof name === "string" && name.trim() ? name.trim() : "Player",
created: now,
lastSeen: now,
apps: {},
};
this.profiles.set(profile.id, profile);
this.save();
return clone(profile);
}
get(profileId) {
if (typeof profileId !== "string" || !profileId) {
return null;
}
const profile = this.profiles.get(profileId);
return profile ? clone(profile) : null;
}
getAll() {
return [...this.profiles.values()].map(clone);
}
touch(profileId) {
const profile = this.profiles.get(profileId);
if (!profile) {
return null;
}
profile.lastSeen = new Date().toISOString();
this.save();
return clone(profile);
}
setName(profileId, name) {
const profile = this.profiles.get(profileId);
if (!profile) {
return null;
}
if (typeof name !== "string" || !name.trim()) {
return clone(profile);
}
profile.name = name.trim();
profile.lastSeen = new Date().toISOString();
this.save();
return clone(profile);
}
getAppData(profileId, appId) {
const profile = this.profiles.get(profileId);
if (!profile) {
return null;
}
if (!profile.apps || typeof profile.apps !== "object") {
profile.apps = {};
}
return clone(profile.apps[appId] ?? null);
}
setAppData(profileId, appId, data) {
const profile = this.profiles.get(profileId);
if (!profile) {
return null;
}
if (typeof appId !== "string" || !appId.trim()) {
throw new TypeError("appId must be a non-empty string");
}
structuredClone(data);
if (!profile.apps || typeof profile.apps !== "object") {
profile.apps = {};
}
profile.apps[appId] = clone(data);
profile.lastSeen = new Date().toISOString();
this.save();
return clone(profile.apps[appId]);
}
delete(profileId) {
const deleted = this.profiles.delete(profileId);
if (deleted) {
this.save();
}
return deleted;
}
}
function clone(value) {
if (value === undefined) {
return undefined;
}
return structuredClone(value);
}
module.exports = {
ProfileStore,
};
.\apps\sensor-tester\client\mobile\mobile.css 3780 caracteres .sensor-tester-mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #0c0e1a;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
}
.sensor-tester-mobile .toolbar {
flex: 0 0 auto;
padding: 14px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.25);
}
.sensor-tester-mobile .toolbar-title {
font-size: 17px;
font-weight: 600;
}
.sensor-tester-mobile .content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
-webkit-overflow-scrolling: touch;
}
/* ============================================================
STATUS
============================================================ */
.sensor-tester-mobile .status {
padding: 16px;
border-radius: 12px;
text-align: center;
}
.sensor-tester-mobile .status-empty {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.sensor-tester-mobile .status-ok {
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.25);
}
.sensor-tester-mobile .status-icon {
font-size: 40px;
margin-bottom: 8px;
}
.sensor-tester-mobile .status-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 6px;
}
.sensor-tester-mobile .status-hint {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.sensor-tester-mobile .perm-btn {
margin-top: 14px;
padding: 12px 24px;
border: none;
border-radius: 10px;
background: rgba(96, 165, 250, 0.2);
color: #93c5fd;
font-size: 15px;
font-weight: 600;
}
.sensor-tester-mobile .perm-btn:active {
background: rgba(96, 165, 250, 0.35);
}
/* ============================================================
SENSOR CARD
============================================================ */
.sensor-tester-mobile .sensor-card {
padding: 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.sensor-tester-mobile .sensor-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 10px;
}
.sensor-tester-mobile .axis-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.sensor-tester-mobile .axis-row:last-child {
margin-bottom: 0;
}
.sensor-tester-mobile .axis-letter {
flex-shrink: 0;
width: 14px;
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.sensor-tester-mobile .axis-bar {
flex: 1;
position: relative;
height: 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.sensor-tester-mobile .axis-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(96, 165, 250, 0.55);
transition: width 0.04s linear;
}
.sensor-tester-mobile .axis-center {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.sensor-tester-mobile .axis-value {
flex-shrink: 0;
width: 52px;
text-align: right;
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.8);
}
.sensor-tester-mobile .orientation-meta {
margin-top: 8px;
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.sensor-tester-mobile .compass-row {
display: flex;
align-items: center;
gap: 14px;
}
.sensor-tester-mobile .compass-arrow {
font-size: 28px;
transition: transform 0.1s linear;
}
.sensor-tester-mobile .compass-value {
font-size: 18px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.8);
} .\public\control.html 4131 caracteres <!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control</title>
<meta name="color-scheme" content="dark" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0c0e1a;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
padding: 20px;
min-height: 100vh;
}
h1 {
font-size: 20px;
margin-bottom: 20px;
}
.section-title {
margin-top: 20px;
margin-bottom: 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.4);
}
.section-title:first-of-type {
margin-top: 0;
}
.player-item {
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.player-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex-shrink: 0;
padding: 4px 8px;
border-radius: 10px;
font-size: 11px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
}
.admin-badge {
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
}
.tv-badge {
background: rgba(96, 165, 250, 0.1);
color: #93c5fd;
}
.suplantar-btn {
flex-shrink: 0;
padding: 8px 16px;
border: 1px solid rgba(167, 139, 250, 0.3);
border-radius: 8px;
background: rgba(167, 139, 250, 0.15);
color: #c4b5fd;
text-decoration: none;
font-size: 13px;
}
.suplantar-btn:active {
background: rgba(167, 139, 250, 0.25);
}
.empty {
padding: 20px;
color: rgba(255, 255, 255, 0.25);
text-align: center;
font-size: 13px;
}
</style>
</head>
<body>
<h1>🎮 Control de sesiones</h1>
<div id="list">Cargando...</div>
<script type="module">
async function refresh() {
try {
const res = await fetch("/control/players");
const players = await res.json();
render(players);
} catch (err) {
console.error(err);
}
}
function render(players) {
const list = document.getElementById("list");
const tvs = players.filter((p) => p.role === "tv");
const mobiles = players.filter((p) => p.role === "mobile");
let html = "";
html += `<div class="section-title">📺 TVs</div>`;
if (tvs.length === 0) {
html += `<div class="empty">No hay TVs conectadas</div>`;
} else {
html += tvs
.map(
(p) => `
<div class="player-item">
<span class="player-name">${escapeHtml(p.name)}</span>
<span class="badge tv-badge">📺 TV</span>
<a class="suplantar-btn" href="/suplantar?suplantar=${p.connectionId}" target="_blank">Suplantar</a>
</div>
`,
)
.join("");
}
html += `<div class="section-title">👥 Jugadores</div>`;
if (mobiles.length === 0) {
html += `<div class="empty">No hay jugadores conectados</div>`;
} else {
html += mobiles
.map(
(p) => `
<div class="player-item">
<span class="player-name">${escapeHtml(p.name)}</span>
${p.isAdmin ? '<span class="badge admin-badge">👑 Admin</span>' : ""}
<a class="suplantar-btn" href="/suplantar?suplantar=${p.connectionId}" target="_blank">Suplantar</a>
</div>
`,
)
.join("");
}
list.innerHTML = html;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html> .\apps\paint\client\mobile\mobile.html 4373 caracteres <div class="paint-mobile">
<div class="toolbar">
<label>🎨</label>
<input type="color" id="colorPicker" value="#FFFFFF">
<label style="margin-left:4px;">📏</label>
<input type="range" id="sizePicker" min="2" max="30" value="5">
<span class="size-label" id="sizeLabel">5</span>
<div class="mode-toggle">
<button id="drawModeBtn" class="active">✏️</button>
<button id="navModeBtn">👆</button>
</div>
<button class="fill-btn" id="fillBtn">🪣</button>
<button class="undo-btn" id="undoBtn" disabled>↩️ Undo</button>
<span class="zoom-info" id="zoomLabel">100%</span>
</div>
<div class="canvas-wrapper" id="canvasWrapper">
<div class="grid"></div>
<canvas id="canvas"></canvas>
</div>
<div class="footer">
<div class="info">👆 <span id="modeLabel">Drawing</span></div>
<div class="zoom-controls">
<button id="zoomOutBtn">➖</button>
<button id="zoomResetBtn">⟲</button>
<button id="zoomInBtn">➕</button>
</div>
<button class="admin-btn" id="adminBtn">👑 Admin</button>
</div>
</div>
<div class="paint-admin-overlay" id="adminPanelOverlay">
<div class="paint-admin-panel" id="adminPanel">
<div class="panel-header">
<h2>👑 Whiteboard Control</h2>
<button class="close-btn" id="closePanelBtn">✕</button>
</div>
<div class="panel-tabs">
<button class="tab-btn active" id="tabBtnCanvas">🖼️ Whiteboard</button>
<button class="tab-btn" id="tabBtnRoom">👥 Room</button>
</div>
<div class="tab-content active" id="tabCanvas">
<div class="panel-section">
<div class="section-label">🔍 Canvas Zoom</div>
<div class="btn-group">
<button class="primary" id="adminZoomInBtn">➕ Zoom in</button>
<button class="primary" id="adminZoomOutBtn">➖ Zoom out</button>
<button class="primary" id="adminZoomResetBtn">⟲ Reset</button>
<button class="primary" id="adminCenterBtn">📐 Center</button>
</div>
</div>
<div class="panel-section">
<div class="section-label">📐 Canvas size</div>
<div class="btn-group">
<button class="primary" id="sizeAutoBtn">Auto</button>
<button class="primary" id="sizeTvBtn">TV</button>
<button class="primary" id="sizeAdminBtn">Admin</button>
</div>
</div>
<div class="panel-section">
<div class="section-label">🖱️ Move whiteboard (drag)</div>
<div class="trackpad" id="trackpad">
<div class="center-mark"></div>
<div class="dot" id="trackpadDot"></div>
<span class="hint">drag to move the canvas on TV</span>
</div>
</div>
<div class="panel-section">
<div class="section-label">🎬 Timeline</div>
<div class="timeline-controls">
<button id="playBtn">▶️ Play</button>
<span class="info" id="timelineInfo">0 / 0</span>
</div>
<input type="range" class="paint-timeline-slider" id="timelineSlider" min="0" max="0" value="0">
</div>
</div>
<div class="tab-content" id="tabRoom">
<div class="panel-section">
<div class="section-label">👥 Players in room</div>
<div class="players-list-panel" id="playersListPanel"></div>
</div>
<div class="panel-section">
<div class="section-label">🗄️ Backups</div>
<div class="btn-group">
<button class="primary" id="refreshBackupsBtn">🔄 Refresh list</button>
</div>
<div class="players-list-panel" id="backupsListPanel"></div>
</div>
<div class="panel-section">
<div class="section-label">⚠️ Danger</div>
<div class="btn-group">
<button class="danger" id="clearBtn">🗑️ Clear all</button>
</div>
</div>
</div>
</div>
</div> .\apps\sensor-tester\client\tv.js 4899 caracteres export function mount(container, sdk) {
let destroyed = false;
let htmlReady = false;
const pendingMessages = [];
let contentEl = null;
const snapshotsByPlayer = new Map();
const unsubscribe = sdk.onApp((data) => {
if (!data) return;
if (!htmlReady) {
pendingMessages.push(data);
return;
}
handleMessage(data);
});
function handleMessage(data) {
if (data.type !== "snapshots") return;
snapshotsByPlayer.clear();
(data.snapshots || []).forEach((snap) => {
snapshotsByPlayer.set(snap.playerId, snap);
});
render();
}
const cssId = "sensor-tester-tv-style";
if (!document.getElementById(cssId)) {
const link = document.createElement("link");
link.id = cssId;
link.rel = "stylesheet";
link.href = `/apps/sensor-tester/client/tv/tv.css`;
document.head.appendChild(link);
}
fetch(`/apps/sensor-tester/client/tv/tv.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
contentEl = container.querySelector("#content");
htmlReady = true;
pendingMessages.forEach(handleMessage);
pendingMessages.length = 0;
render();
})
.catch((err) => {
console.error("[sensor-tester/tv] Error cargando HTML:", err);
});
function render() {
if (!contentEl) return;
const snapshots = Array.from(snapshotsByPlayer.values()).filter(
(s) => s.sensors,
);
if (snapshots.length === 0) {
contentEl.innerHTML = `
<div class="empty">
<div class="empty-icon">📡</div>
<div class="empty-title">No hay dispositivos con sensores</div>
<div class="empty-hint">
Abre la app <strong>Sensor Tester</strong> en un móvil.
</div>
</div>
`;
return;
}
contentEl.innerHTML = snapshots
.map((s) => renderPlayerCard(s))
.join("");
}
function renderPlayerCard(snapshot) {
const s = snapshot.sensors;
return `
<div class="player-card">
<div class="player-header">
<span class="player-role">${snapshot.playerRole === "tv" ? "📺" : "📱"}</span>
<span class="player-name">${escapeHtml(snapshot.playerName)}</span>
</div>
${renderVector3("Aceleración (sin gravedad)", s.acceleration)}
${renderVector3("Aceleración (con gravedad)", s.accelerationIncludingGravity)}
${renderVector3("Rotación (giroscopio)", s.rotationRate)}
${renderOrientation(s.orientation)}
${renderCompass(s.compass)}
</div>
`;
}
function renderVector3(label, vec) {
if (!vec) return "";
const x = vec.x ?? vec.alpha ?? 0;
const y = vec.y ?? vec.beta ?? 0;
const z = vec.z ?? vec.gamma ?? 0;
return `
<div class="section">
<div class="section-label">${escapeHtml(label)}</div>
<div class="vector-row">
${renderAxisMini("X", x)}
${renderAxisMini("Y", y)}
${renderAxisMini("Z", z)}
</div>
</div>
`;
}
function renderAxisMini(letter, value) {
const normalized = Math.max(-10, Math.min(10, value));
const percent = ((normalized + 10) / 20) * 100;
return `
<div class="axis-mini">
<div class="axis-letter">${letter}</div>
<div class="axis-track">
<div class="axis-fill" style="width: ${percent}%"></div>
<div class="axis-center"></div>
</div>
<div class="axis-num">${value.toFixed(2)}</div>
</div>
`;
}
function renderOrientation(o) {
if (!o) return "";
return `
<div class="section">
<div class="section-label">Orientación (alpha, beta, gamma)</div>
<div class="vector-row">
${renderAxisMini("α", o.alpha || 0)}
${renderAxisMini("β", o.beta || 0)}
${renderAxisMini("γ", o.gamma || 0)}
</div>
<div class="orientation-meta">
${o.absolute ? "Absoluta" : "Relativa"}
</div>
</div>
`;
}
function renderCompass(heading) {
if (heading === null || heading === undefined) return "";
return `
<div class="section">
<div class="section-label">Brújula</div>
<div class="compass">
<div class="compass-arrow" style="transform: rotate(${heading}deg)">⬆</div>
<div class="compass-value">${heading.toFixed(0)}°</div>
</div>
</div>
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
return function unmount() {
destroyed = true;
if (typeof unsubscribe === "function") {
unsubscribe();
}
snapshotsByPlayer.clear();
pendingMessages.length = 0;
};
} .\public\gamepad.js 4990 caracteres // public/gamepad.js
const DEADZONE = 0.15;
const POLL_INTERVAL_MS = 8;
const prevButtons = {};
const prevAxes = {};
let running = false;
let onEvent = null;
let timerId = null;
let lastTime = 0;
const connectedGamepads = new Map();
function poll() {
if (!running) return;
const now = performance.now();
if (now - lastTime < POLL_INTERVAL_MS) {
timerId = setTimeout(poll, 1);
return;
}
lastTime = now;
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (const pad of pads) {
if (!pad) continue;
const id = pad.index;
const prevB = prevButtons[id] || [];
pad.buttons.forEach((btn, i) => {
const pressed = btn.pressed;
if (pressed !== prevB[i]) {
emitEvent({
gamepadId: id,
gamepadName: pad.id,
mapping: pad.mapping,
control: "button",
index: i,
pressed,
value: btn.value,
});
}
});
prevButtons[id] = pad.buttons.map((b) => b.pressed);
const prevA = prevAxes[id] || [];
pad.axes.forEach((val, i) => {
const v = Math.abs(val) < DEADZONE ? 0 : val;
const p = prevA[i] || 0;
if (Math.abs(v - p) > 0.01) {
emitEvent({
gamepadId: id,
gamepadName: pad.id,
mapping: pad.mapping,
control: "axis",
index: i,
value: v,
});
}
});
prevAxes[id] = pad.axes.map((v) => (Math.abs(v) < DEADZONE ? 0 : v));
}
timerId = setTimeout(poll, 1);
}
function emitEvent(evt) {
if (!onEvent) return;
evt.timestamp = performance.now();
onEvent(evt);
}
function handleConnected(e) {
const pad = e.gamepad;
connectedGamepads.set(pad.index, {
id: pad.index,
name: pad.id,
mapping: pad.mapping,
});
emitEvent({
gamepadId: pad.index,
gamepadName: pad.id,
mapping: pad.mapping,
control: "connected",
});
}
function handleDisconnected(e) {
const pad = e.gamepad;
connectedGamepads.delete(pad.index);
delete prevButtons[pad.index];
delete prevAxes[pad.index];
emitEvent({
gamepadId: pad.index,
gamepadName: pad.id,
mapping: pad.mapping,
control: "disconnected",
});
}
export function startGamepadListener(callback) {
if (running) return;
running = true;
onEvent = callback;
window.addEventListener("gamepadconnected", handleConnected);
window.addEventListener("gamepaddisconnected", handleDisconnected);
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (const pad of pads) {
if (!pad) continue;
if (!connectedGamepads.has(pad.index)) {
connectedGamepads.set(pad.index, {
id: pad.index,
name: pad.id,
mapping: pad.mapping,
});
emitEvent({
gamepadId: pad.index,
gamepadName: pad.id,
mapping: pad.mapping,
control: "connected",
});
}
}
timerId = setTimeout(poll, 1);
}
export function stopGamepadListener() {
running = false;
onEvent = null;
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
window.removeEventListener("gamepadconnected", handleConnected);
window.removeEventListener("gamepaddisconnected", handleDisconnected);
for (const k of Object.keys(prevButtons)) delete prevButtons[k];
for (const k of Object.keys(prevAxes)) delete prevAxes[k];
connectedGamepads.clear();
}
export function getConnectedGamepads() {
return Array.from(connectedGamepads.values());
}
export function getSnapshot() {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const result = [];
for (const pad of pads) {
if (!pad) continue;
result.push({
id: pad.index,
name: pad.id,
mapping: pad.mapping,
buttons: pad.buttons.map((b) => ({
pressed: b.pressed,
touched: b.touched,
value: b.value,
})),
axes: pad.axes.slice(),
timestamp: performance.now(),
});
}
return result;
}
export function hasAnyGamepad() {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (const pad of pads) {
if (pad) return true;
}
return false;
}
const REMAP_STORAGE_KEY = "core.gamepad.remap";
export function loadRemap() {
try {
const raw = localStorage.getItem(REMAP_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
} catch (_) {}
return {};
}
export function saveRemap(remap) {
try {
localStorage.setItem(REMAP_STORAGE_KEY, JSON.stringify(remap));
} catch (_) {}
}
export function clearRemap() {
try {
localStorage.removeItem(REMAP_STORAGE_KEY);
} catch (_) {}
}
export function resolveAction(evt, remap) {
if (!evt) return null;
if (evt.control === "connected" || evt.control === "disconnected") {
return evt.control;
}
const key = evt.control === "axis"
? `axis:${evt.index}`
: `button:${evt.index}`;
return remap[key] || null;
} .\apps\dice\client\mobile\mobile.html 5025 caracteres <div class="mobile">
<div class="toolbar">
<button class="settings-btn" id="settingsBtn">⚙️ Settings</button>
<span id="counter" style="margin-left:auto; font-size:13px; color:rgba(255,255,255,0.6);">0 dice</span>
</div>
<div class="content">
<div class="dice-app">
<div class="dice-selector">
<button class="dice-option" data-type="d4">D4</button>
<button class="dice-option" data-type="d6">D6</button>
<button class="dice-option" data-type="d8">D8</button>
<button class="dice-option" data-type="d10">D10</button>
<button class="dice-option" data-type="d100">D%</button>
<button class="dice-option" data-type="d12">D12</button>
<button class="dice-option" data-type="d20">D20</button>
</div>
<div class="hand-area">
<div class="hand-header">
<span>Your dice hand</span>
<span id="sumaTotalMano" style="color:#ffd700; font-weight:bold;">0</span>
</div>
<div class="dice-hand" id="diceHand">
<div class="empty-state" id="emptyState">
Tap above to add dice
</div>
</div>
<div class="hand-header" style="margin-top:10px;">
<span>Dice on table</span>
<span id="sumaTotalMesa" style="color:#ffd700; font-weight:bold;">0</span>
</div>
<div class="dice-hand" id="mesaDiceHand">
<div class="empty-state" id="mesaEmptyState">
No dice on the table
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<button class="footer-btn btn-lanzar" id="btnLanzar">🎲 Roll</button>
<button class="footer-btn btn-eliminar" id="btnEliminar">🗑️ Delete</button>
<button class="footer-btn btn-recoger" id="btnRecoger">📦 Pick up</button>
</div>
<div class="settings-overlay" id="settingsOverlay">
<div class="settings-panel">
<div class="panel-header">
<h2>⚙️ Dice Settings</h2>
<button class="close-btn" id="closeSettingsBtn">✕</button>
</div>
<div class="panel-content">
<div class="setting-section">
<div class="section-label">🎨 Dice color</div>
<input type="color" id="colorPicker" value="#FF0000">
</div>
<div class="setting-section">
<div class="section-label">📏 Size</div>
<div class="slider-container">
<input type="range" id="scaleSlider" min="1" max="20" value="6" step="0.5">
<span class="slider-value" id="scaleValue">6</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">🌍 Gravity</div>
<div class="slider-container">
<input type="range" id="gravitySlider" min="0" max="5" value="3" step="0.1">
<span class="slider-value" id="gravityValue">3.0</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">💨 Friction</div>
<div class="slider-container">
<input type="range" id="frictionSlider" min="0" max="2" value="0.4" step="0.05">
<span class="slider-value" id="frictionValue">0.40</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">🏀 Bounce</div>
<div class="slider-container">
<input type="range" id="restitutionSlider" min="0" max="1" value="1.0" step="0.05">
<span class="slider-value" id="restitutionValue">1.0</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">⚖️ Mass</div>
<div class="slider-container">
<input type="range" id="massSlider" min="0.1" max="10" value="4" step="0.1">
<span class="slider-value" id="massValue">4.0</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">⏱️ Time between rolls</div>
<div class="slider-container">
<input type="range" id="launchSlider" min="0" max="5000" value="0" step="50">
<span class="slider-value" id="launchValue">0</span>
</div>
</div>
</div>
</div>
</div>
</div> .\apps\gamepad-tester\client\tv.js 5234 caracteres export function mount(container, sdk) {
let destroyed = false;
let htmlReady = false;
const pendingMessages = [];
let contentEl = null;
const snapshotsByPlayer = new Map();
const unsubscribe = sdk.onApp((data) => {
if (!data) return;
if (!htmlReady) {
pendingMessages.push(data);
return;
}
handleMessage(data);
});
function handleMessage(data) {
if (data.type !== "snapshots") return;
snapshotsByPlayer.clear();
(data.snapshots || []).forEach((snap) => {
snapshotsByPlayer.set(snap.playerId, snap);
});
render();
}
const cssId = "gamepad-tester-tv-style";
if (!document.getElementById(cssId)) {
const link = document.createElement("link");
link.id = cssId;
link.rel = "stylesheet";
link.href = `/apps/gamepad-tester/client/tv/tv.css`;
document.head.appendChild(link);
}
fetch(`/apps/gamepad-tester/client/tv/tv.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
contentEl = container.querySelector("#content");
htmlReady = true;
pendingMessages.forEach(handleMessage);
pendingMessages.length = 0;
render();
})
.catch((err) => {
console.error("[gamepad-tester/tv] Error cargando HTML:", err);
});
function render() {
if (!contentEl) return;
const snapshots = Array.from(snapshotsByPlayer.values());
if (snapshots.length === 0) {
contentEl.innerHTML = `
<div class="empty">
<div class="empty-icon">🎮</div>
<div class="empty-title">No hay dispositivos con mandos</div>
<div class="empty-hint">
Conecta un mando por Bluetooth a un móvil o a esta TV.
</div>
</div>
`;
return;
}
const withGamepads = snapshots.filter(
(s) => s.gamepads && s.gamepads.length > 0,
);
if (withGamepads.length === 0) {
contentEl.innerHTML = `
<div class="empty">
<div class="empty-icon">🔍</div>
<div class="empty-title">Esperando mandos...</div>
<div class="empty-hint">
La app está activa pero no detecta mandos conectados.
Pulsa cualquier botón del mando para que el navegador lo detecte.
</div>
</div>
`;
return;
}
contentEl.innerHTML = withGamepads
.map((snapshot) => renderPlayerCard(snapshot))
.join("");
}
function renderPlayerCard(snapshot) {
return `
<div class="player-card">
<div class="player-header">
<span class="player-role">${snapshot.playerRole === "tv" ? "📺" : "📱"}</span>
<span class="player-name">${escapeHtml(snapshot.playerName)}</span>
<span class="gamepad-count">
${snapshot.gamepads.length} mando${snapshot.gamepads.length === 1 ? "" : "s"}
</span>
</div>
${snapshot.gamepads.map((gp) => renderGamepad(gp)).join("")}
</div>
`;
}
function renderGamepad(gp) {
return `
<div class="gamepad">
<div class="gamepad-header">
<span class="gamepad-name">${escapeHtml(gp.name)}</span>
<span class="gamepad-mapping ${gp.mapping === "standard" ? "standard" : ""}">
${gp.mapping === "standard" ? "estándar" : "no estándar"}
</span>
</div>
<div class="buttons-section">
<div class="section-label">Botones</div>
<div class="buttons-grid">
${gp.buttons.map((btn, i) => renderButton(i, btn)).join("")}
</div>
</div>
<div class="axes-section">
<div class="section-label">Ejes</div>
<div class="axes-list">
${gp.axes.map((val, i) => renderAxis(i, val)).join("")}
</div>
</div>
</div>
`;
}
function renderButton(index, btn) {
const pressed = btn.pressed || btn.touched;
const value = typeof btn.value === "number" ? btn.value : 0;
const fillPercent = Math.round(value * 100);
return `
<div class="button-cell ${pressed ? "pressed" : ""}">
<div class="button-fill" style="height: ${fillPercent}%"></div>
<div class="button-index">${index}</div>
<div class="button-value">${value.toFixed(2)}</div>
</div>
`;
}
function renderAxis(index, value) {
const normalized = (value + 1) / 2;
const fillPercent = Math.round(normalized * 100);
return `
<div class="axis-row">
<div class="axis-index">${index}</div>
<div class="axis-bar">
<div class="axis-fill" style="width: ${fillPercent}%"></div>
<div class="axis-center"></div>
</div>
<div class="axis-value">${value.toFixed(3)}</div>
</div>
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
return function unmount() {
destroyed = true;
if (typeof unsubscribe === "function") {
unsubscribe();
}
snapshotsByPlayer.clear();
pendingMessages.length = 0;
};
} .\apps\gamepad-tester\client\mobile\mobile.css 5405 caracteres .gamepad-tester-mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #0c0e1a;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
}
/* ============================================================
TOOLBAR
============================================================ */
.gamepad-tester-mobile .toolbar {
flex: 0 0 auto;
padding: 14px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.25);
}
.gamepad-tester-mobile .toolbar-title {
font-size: 17px;
font-weight: 600;
}
/* ============================================================
CONTENT (scrollable)
============================================================ */
.gamepad-tester-mobile .content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
-webkit-overflow-scrolling: touch;
}
/* ============================================================
STATUS
============================================================ */
.gamepad-tester-mobile .status {
padding: 16px;
border-radius: 12px;
text-align: center;
}
.gamepad-tester-mobile .status-empty {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.gamepad-tester-mobile .status-ok {
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.25);
}
.gamepad-tester-mobile .status-icon {
font-size: 40px;
margin-bottom: 8px;
}
.gamepad-tester-mobile .status-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 6px;
}
.gamepad-tester-mobile .status-hint {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
/* ============================================================
GAMEPAD CARD
============================================================ */
.gamepad-tester-mobile .gamepad-card {
padding: 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.gamepad-tester-mobile .gamepad-name {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
margin-bottom: 8px;
word-break: break-word;
}
.gamepad-tester-mobile .gamepad-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.gamepad-tester-mobile .gamepad-stats .mapping {
padding: 2px 8px;
border-radius: 6px;
background: rgba(167, 139, 250, 0.12);
color: #c4b5fd;
}
/* ============================================================
MINI BOTONES
============================================================ */
.gamepad-tester-mobile .mini-buttons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 5px;
margin-bottom: 12px;
}
.gamepad-tester-mobile .mini-button {
aspect-ratio: 1;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
transition:
background 0.06s,
border-color 0.06s,
transform 0.06s;
}
.gamepad-tester-mobile .mini-button.pressed {
background: rgba(96, 165, 250, 0.3);
border-color: rgba(96, 165, 250, 0.7);
color: #fff;
transform: scale(0.92);
}
/* ============================================================
MINI EJES
============================================================ */
.gamepad-tester-mobile .mini-axes {
display: flex;
flex-direction: column;
gap: 6px;
}
.gamepad-tester-mobile .mini-axis {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.gamepad-tester-mobile .mini-axis .axis-label {
flex-shrink: 0;
width: 14px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
}
.gamepad-tester-mobile .mini-axis .axis-bar {
flex: 1;
position: relative;
height: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.gamepad-tester-mobile .mini-axis .axis-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(96, 165, 250, 0.55);
transition: width 0.04s linear;
}
.gamepad-tester-mobile .mini-axis .axis-center {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.gamepad-tester-mobile .mini-axis .axis-value {
flex-shrink: 0;
width: 48px;
text-align: right;
color: rgba(255, 255, 255, 0.8);
}
/* ============================================================
SERVER VIEW
============================================================
Fijo abajo, fuera del contenedor con scroll.
============================================================ */
.gamepad-tester-mobile #server-view {
flex: 0 0 auto;
padding: 10px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.35);
font-size: 13px;
}
.gamepad-tester-mobile #server-view .server-view-title {
color: #c4b5fd;
font-weight: 600;
margin-bottom: 2px;
}
.gamepad-tester-mobile #server-view .server-view-content {
color: rgba(255, 255, 255, 0.6);
} .\apps\gamepad-tester\client\tv\tv.css 5459 caracteres .gamepad-tester-tv {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #0e0f18;
color: #e0e0e0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.gamepad-tester-tv .toolbar {
flex: 0 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.25);
}
.gamepad-tester-tv .toolbar-title {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.02em;
}
.gamepad-tester-tv .content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
gap: 20px;
align-content: start;
}
.gamepad-tester-tv .empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 24px;
color: rgba(255, 255, 255, 0.4);
}
.gamepad-tester-tv .empty-icon {
font-size: 72px;
margin-bottom: 16px;
opacity: 0.5;
}
.gamepad-tester-tv .empty-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.7);
}
.gamepad-tester-tv .empty-hint {
font-size: 15px;
max-width: 500px;
line-height: 1.5;
}
.gamepad-tester-tv .player-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 18px;
}
.gamepad-tester-tv .player-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.gamepad-tester-tv .player-role {
font-size: 22px;
}
.gamepad-tester-tv .player-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 600;
}
.gamepad-tester-tv .gamepad-count {
flex-shrink: 0;
font-size: 12px;
padding: 3px 8px;
border-radius: 8px;
background: rgba(167, 139, 250, 0.12);
color: #c4b5fd;
}
.gamepad-tester-tv .gamepad {
margin-bottom: 16px;
}
.gamepad-tester-tv .gamepad:last-child {
margin-bottom: 0;
}
.gamepad-tester-tv .gamepad-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.gamepad-tester-tv .gamepad-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.gamepad-tester-tv .gamepad-mapping {
flex-shrink: 0;
font-size: 11px;
padding: 3px 8px;
border-radius: 6px;
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.gamepad-tester-tv .gamepad-mapping.standard {
background: rgba(52, 211, 153, 0.12);
color: #6ee7b7;
}
.gamepad-tester-tv .section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 8px;
}
.gamepad-tester-tv .buttons-section,
.gamepad-tester-tv .axes-section {
margin-bottom: 14px;
}
.gamepad-tester-tv .axes-section:last-child {
margin-bottom: 0;
}
.gamepad-tester-tv .buttons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 6px;
}
.gamepad-tester-tv .button-cell {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
transition:
background 0.08s,
border-color 0.08s,
transform 0.08s;
}
.gamepad-tester-tv .button-cell.pressed {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(96, 165, 250, 0.6);
transform: scale(0.94);
}
.gamepad-tester-tv .button-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(96, 165, 250, 0.35);
pointer-events: none;
transition: height 0.05s;
}
.gamepad-tester-tv .button-index {
position: relative;
z-index: 1;
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.gamepad-tester-tv .button-value {
position: relative;
z-index: 1;
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
margin-top: 2px;
}
.gamepad-tester-tv .axes-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.gamepad-tester-tv .axis-row {
display: flex;
align-items: center;
gap: 10px;
}
.gamepad-tester-tv .axis-index {
flex-shrink: 0;
width: 22px;
text-align: center;
font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.6);
}
.gamepad-tester-tv .axis-bar {
flex: 1;
position: relative;
height: 22px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.gamepad-tester-tv .axis-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(167, 139, 250, 0.35);
transition: width 0.05s;
}
.gamepad-tester-tv .axis-center {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.15);
pointer-events: none;
}
.gamepad-tester-tv .axis-value {
flex-shrink: 0;
width: 70px;
text-align: right;
font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: rgba(255, 255, 255, 0.7);
} .\apps\maps\client\mobile\mobile.html 5528 caracteres <div class="mobile">
<div class="toolbar">
<span style="font-size:13px; color:rgba(255,255,255,0.6);" id="rutaCount">0 routes</span>
<button class="settings-btn" id="settingsBtn" style="margin-left:auto;">⚙️ Control</button>
</div>
<div class="content">
<div class="rutas-app">
<div class="search-section" id="searchSection">
<div class="search-header">
<h3>🔍 Search place (hold and drag to a route)</h3>
</div>
<div class="search-input-row">
<div class="search-input-wrapper">
<input type="text" id="searchInput" class="search-input" placeholder="Type to search...">
<button id="btnClearSearch" class="btn-clear-search">✕</button>
</div>
</div>
<div id="searchResults" class="search-results"></div>
</div>
<div class="ruta-content" id="rutaContent"></div>
<div class="tabs-bottom">
<div class="tabs-container" id="tabsContainer">
<div class="tabs-track" id="tabsTrack"></div>
</div>
<button class="btn-new-tab" id="btnNuevaRuta">+</button>
</div>
</div>
</div>
<div class="settings-overlay" id="settingsOverlay">
<div class="settings-panel">
<div class="panel-header">
<h2>⚙️ Control</h2>
<button class="close-btn" id="closeSettingsBtn">✕</button>
</div>
<div class="panel-tabs">
<button class="panel-tab active" id="tabBtnMapa">🗺️ Map</button>
<button class="panel-tab" id="tabBtnGuardadas">💾 Saved</button>
</div>
<div class="tab-content active" id="tabMapa">
<div class="panel-content">
<div class="setting-section">
<div class="section-label">🖱️ Move map (drag)</div>
<div class="trackpad" id="trackpad">
<div class="center-mark"></div>
<div class="dot" id="trackpadDot"></div>
<span class="hint">drag to move the map</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">📏 Sensitivity</div>
<div class="slider-container">
<input type="range" id="sensibilidadSlider" min="3" max="100" value="15" step="1">
<span class="slider-value" id="sensibilidadValue">15</span>
</div>
</div>
<div class="setting-section">
<div class="section-label">🔍 Zoom and Rotation</div>
<div style="display:flex; gap:10px; align-items:center;">
<div class="btn-group" style="flex-shrink:0;">
<button class="primary" id="zoomInBtn">➕</button>
<button class="primary" id="zoomOutBtn">➖</button>
<button class="primary" id="zoomResetBtn">⟲</button>
</div>
<div class="slider-container" style="flex:1;">
<input type="range" id="rotacionSlider" min="0" max="360" value="0" step="1">
<span class="slider-value" id="rotacionValue">0°</span>
</div>
</div>
</div>
<div class="setting-section">
<div class="section-label">📍 Center pin</div>
<div class="chincheta-row">
<div class="coords-column">
<input type="number" class="coord-input" id="panelCoordLat" placeholder="Latitude (Y)" step="0.00000001">
<input type="number" class="coord-input" id="panelCoordLng" placeholder="Longitude (X)" step="0.00000001">
</div>
<div class="chincheta-actions">
<button class="btn-toggle-chincheta" id="btnToggleChincheta">📍</button>
<button class="btn-add-coords" id="btnAddCoords">+</button>
</div>
</div>
</div>
<div class="setting-section">
<div class="section-label">🗑️ Clear all</div>
<div class="btn-group">
<button class="danger" id="btnLimpiarPanel">🗑️ Clear all</button>
</div>
</div>
</div>
</div>
<div class="tab-content" id="tabGuardadas">
<div class="setting-section">
<div class="section-label">💾 Saved routes (hold 5s to delete)</div>
<div class="saved-rutas-list" id="savedRutasList">
<div style="text-align:center; color:rgba(255,255,255,0.3); padding:10px; font-size:12px;">
No saved routes
</div>
</div>
</div>
</div>
</div>
</div>
</div> .\public\sensors.js 5757 caracteres // public/sensors.js
//
// Lee los sensores del dispositivo (acelerómetro, giroscopio,
// orientación, brújula) y expone un snapshot con el estado actual.
//
// El módulo no pide permisos por sí solo. Solo los pide cuando
// se llama explícitamente a `requestSensorPermissions()`. La app
// decide si necesita pedirlos o no.
const DEADZONE_ACCEL = 0.05;
const DEADZONE_ROTATION = 0.01;
const DEADZONE_ORIENTATION = 0.1;
const DEADZONE_COMPASS = 0.5;
let running = false;
let onEvent = null;
const lastAcceleration = { x: 0, y: 0, z: 0 };
const lastAccelerationIncludingGravity = { x: 0, y: 0, z: 0 };
const lastRotationRate = { alpha: 0, beta: 0, gamma: 0 };
const lastOrientation = { alpha: 0, beta: 0, gamma: 0, absolute: false };
let lastCompass = null;
let hasMotionPermission = false;
let hasOrientationPermission = false;
export async function requestSensorPermissions() {
let motionPromise = null;
let orientationPromise = null;
// Lanzamos los dos SIN await, para que iOS no consuma
// el user gesture token antes del segundo.
try {
if (
typeof DeviceMotionEvent !== "undefined" &&
typeof DeviceMotionEvent.requestPermission === "function"
) {
motionPromise = DeviceMotionEvent.requestPermission().catch(
() => "denied",
);
} else if (typeof DeviceMotionEvent !== "undefined") {
motionPromise = Promise.resolve("granted");
} else {
motionPromise = Promise.resolve("denied");
}
} catch (_) {
motionPromise = Promise.resolve("denied");
}
try {
if (
typeof DeviceOrientationEvent !== "undefined" &&
typeof DeviceOrientationEvent.requestPermission === "function"
) {
orientationPromise = DeviceOrientationEvent.requestPermission().catch(
() => "denied",
);
} else if (typeof DeviceOrientationEvent !== "undefined") {
orientationPromise = Promise.resolve("granted");
} else {
orientationPromise = Promise.resolve("denied");
}
} catch (_) {
orientationPromise = Promise.resolve("denied");
}
const [motionResult, orientationResult] = await Promise.all([
motionPromise,
orientationPromise,
]);
hasMotionPermission = motionResult === "granted";
hasOrientationPermission = orientationResult === "granted";
return {
motion: hasMotionPermission,
orientation: hasOrientationPermission,
};
}
export function getSensorPermissionState() {
return {
motion: hasMotionPermission,
orientation: hasOrientationPermission,
};
}
function handleMotion(e) {
if (!onEvent) return;
const acc = e.acceleration || {};
const accG = e.accelerationIncludingGravity || {};
const rot = e.rotationRate || {};
const ax = typeof acc.x === "number" ? acc.x : 0;
const ay = typeof acc.y === "number" ? acc.y : 0;
const az = typeof acc.z === "number" ? acc.z : 0;
if (
Math.abs(ax - lastAcceleration.x) > DEADZONE_ACCEL ||
Math.abs(ay - lastAcceleration.y) > DEADZONE_ACCEL ||
Math.abs(az - lastAcceleration.z) > DEADZONE_ACCEL
) {
lastAcceleration.x = ax;
lastAcceleration.y = ay;
lastAcceleration.z = az;
onEvent({ sensor: "acceleration", x: ax, y: ay, z: az });
}
lastAccelerationIncludingGravity.x =
typeof accG.x === "number" ? accG.x : 0;
lastAccelerationIncludingGravity.y =
typeof accG.y === "number" ? accG.y : 0;
lastAccelerationIncludingGravity.z =
typeof accG.z === "number" ? accG.z : 0;
const ra = typeof rot.alpha === "number" ? rot.alpha : 0;
const rb = typeof rot.beta === "number" ? rot.beta : 0;
const rg = typeof rot.gamma === "number" ? rot.gamma : 0;
if (
Math.abs(ra - lastRotationRate.alpha) > DEADZONE_ROTATION ||
Math.abs(rb - lastRotationRate.beta) > DEADZONE_ROTATION ||
Math.abs(rg - lastRotationRate.gamma) > DEADZONE_ROTATION
) {
lastRotationRate.alpha = ra;
lastRotationRate.beta = rb;
lastRotationRate.gamma = rg;
onEvent({ sensor: "rotation", alpha: ra, beta: rb, gamma: rg });
}
}
function handleOrientation(e) {
if (!onEvent) return;
const a = typeof e.alpha === "number" ? e.alpha : 0;
const b = typeof e.beta === "number" ? e.beta : 0;
const g = typeof e.gamma === "number" ? e.gamma : 0;
if (
Math.abs(a - lastOrientation.alpha) > DEADZONE_ORIENTATION ||
Math.abs(b - lastOrientation.beta) > DEADZONE_ORIENTATION ||
Math.abs(g - lastOrientation.gamma) > DEADZONE_ORIENTATION
) {
lastOrientation.alpha = a;
lastOrientation.beta = b;
lastOrientation.gamma = g;
lastOrientation.absolute = !!e.absolute;
onEvent({
sensor: "orientation",
alpha: a,
beta: b,
gamma: g,
absolute: !!e.absolute,
});
}
if (typeof e.webkitCompassHeading === "number") {
if (
lastCompass === null ||
Math.abs(e.webkitCompassHeading - lastCompass) > DEADZONE_COMPASS
) {
lastCompass = e.webkitCompassHeading;
onEvent({ sensor: "compass", heading: lastCompass });
}
}
}
export function startSensorListener(callback) {
if (running) return;
running = true;
onEvent = callback;
window.addEventListener("devicemotion", handleMotion);
window.addEventListener("deviceorientation", handleOrientation);
}
export function stopSensorListener() {
running = false;
onEvent = null;
window.removeEventListener("devicemotion", handleMotion);
window.removeEventListener("deviceorientation", handleOrientation);
}
export function getSensorSnapshot() {
return {
acceleration: { ...lastAcceleration },
accelerationIncludingGravity: { ...lastAccelerationIncludingGravity },
rotationRate: { ...lastRotationRate },
orientation: { ...lastOrientation },
compass: lastCompass,
timestamp: performance.now(),
};
} .\apps\sensor-tester\client\mobile.js 6719 caracteres export function mount(container, sdk) {
let destroyed = false;
let htmlReady = false;
let statusEl = null;
let sensorListEl = null;
let localRenderTimer = null;
const unsubscribe = sdk.onApp(() => {});
// ============================================================
// ACTIVAR SENSORES
// ============================================================
//
// Primer intento sin permisos. En Android HTTPS/localhost
// funciona directamente. En iOS o Android HTTP por IP, no
// llegará nada hasta que el usuario pulse el botón.
function activateSensors() {
try {
sdk.enableSensors();
} catch (err) {
console.warn("[sensor-tester] Error activando sensores:", err);
}
}
activateSensors();
async function requestPermissionsAndActivate() {
alert("botón pulsado");
try {
const result = await sdk.requestSensorPermissions();
alert("permisos: " + JSON.stringify(result));
} catch (err) {
alert("error: " + err.message);
}
activateSensors();
alert("sensores activados");
renderLocal();
}
// ============================================================
// RENDER LOCAL
// ============================================================
function renderLocal() {
if (destroyed) return;
if (!statusEl || !sensorListEl) return;
const snapshot = sdk.getSensorSnapshot
? sdk.getSensorSnapshot()
: null;
const perms = sdk.getSensorPermissionState
? sdk.getSensorPermissionState()
: { motion: false, orientation: false };
if (!snapshot) {
statusEl.innerHTML = `
<div class="status status-empty">
<div class="status-icon">📡</div>
<div class="status-title">Sensores no disponibles</div>
<div class="status-hint">
Este dispositivo no expone sensores de movimiento.
</div>
</div>
`;
sensorListEl.innerHTML = "";
return;
}
if (!perms.motion && !perms.orientation) {
statusEl.innerHTML = `
<div class="status status-empty">
<div class="status-icon">📡</div>
<div class="status-title">Activa los sensores</div>
<div class="status-hint">
Pulsa el botón para empezar a leer los sensores.
En iOS se pedirá permiso explícito.
</div>
<button class="perm-btn" id="perm-btn">Activar sensores</button>
</div>
`;
const btn = document.getElementById("perm-btn");
if (btn) {
btn.onclick = requestPermissionsAndActivate;
}
sensorListEl.innerHTML = "";
return;
}
statusEl.innerHTML = `
<div class="status status-ok">
<div class="status-icon">✅</div>
<div class="status-title">Sensores activos</div>
<div class="status-hint">
Movimiento: ${perms.motion ? "✓" : "✗"} · Orientación: ${perms.orientation ? "✓" : "✗"}
</div>
</div>
`;
sensorListEl.innerHTML = renderSensors(snapshot);
}
function renderSensors(s) {
let html = "";
html += renderVector("Aceleración (sin gravedad)", s.acceleration);
html += renderVector(
"Aceleración (con gravedad)",
s.accelerationIncludingGravity,
);
html += renderVector("Rotación (giroscopio)", s.rotationRate);
html += renderOrientation(s.orientation);
html += renderCompass(s.compass);
return html;
}
function renderVector(label, vec) {
if (!vec) return "";
const x = vec.x ?? vec.alpha ?? 0;
const y = vec.y ?? vec.beta ?? 0;
const z = vec.z ?? vec.gamma ?? 0;
return `
<div class="sensor-card">
<div class="sensor-label">${escapeHtml(label)}</div>
${renderAxis("X", x)}
${renderAxis("Y", y)}
${renderAxis("Z", z)}
</div>
`;
}
function renderAxis(letter, value) {
const normalized = Math.max(-10, Math.min(10, value));
const percent = ((normalized + 10) / 20) * 100;
return `
<div class="axis-row">
<div class="axis-letter">${letter}</div>
<div class="axis-bar">
<div class="axis-fill" style="width: ${percent}%"></div>
<div class="axis-center"></div>
</div>
<div class="axis-value">${value.toFixed(2)}</div>
</div>
`;
}
function renderOrientation(o) {
if (!o) return "";
return `
<div class="sensor-card">
<div class="sensor-label">Orientación</div>
${renderAxis("α", o.alpha || 0)}
${renderAxis("β", o.beta || 0)}
${renderAxis("γ", o.gamma || 0)}
<div class="orientation-meta">
${o.absolute ? "Absoluta" : "Relativa"}
</div>
</div>
`;
}
function renderCompass(heading) {
if (heading === null || heading === undefined) return "";
return `
<div class="sensor-card">
<div class="sensor-label">Brújula</div>
<div class="compass-row">
<div class="compass-arrow" style="transform: rotate(${heading}deg)">⬆</div>
<div class="compass-value">${heading.toFixed(0)}°</div>
</div>
</div>
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
// ============================================================
// CARGA
// ============================================================
const cssId = "sensor-tester-mobile-style";
if (!document.getElementById(cssId)) {
const link = document.createElement("link");
link.id = cssId;
link.rel = "stylesheet";
link.href = `/apps/sensor-tester/client/mobile/mobile.css`;
document.head.appendChild(link);
}
fetch(`/apps/sensor-tester/client/mobile/mobile.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
statusEl = container.querySelector("#status");
sensorListEl = container.querySelector("#sensor-list");
htmlReady = true;
renderLocal();
localRenderTimer = setInterval(() => {
renderLocal();
}, 50);
})
.catch((err) => {
console.error("[sensor-tester/mobile] Error cargando HTML:", err);
});
return function unmount() {
destroyed = true;
if (localRenderTimer) {
clearInterval(localRenderTimer);
localRenderTimer = null;
}
if (typeof unsubscribe === "function") {
unsubscribe();
}
try {
sdk.disableSensors();
} catch (err) {
console.warn("[sensor-tester] Error desactivando sensores:", err);
}
};
} .\summaries\deepseek.md 7313 caracteres # Mobile Games Platform — Resumen de progreso
## Contexto
Proyecto personal de una plataforma de juegos multijugador locales, tipo Jackbox pero self-hosted. El usuario tiene un PC/RPi que ejecuta un core (servidor Node con Express + WebSocket), y los jugadores se conectan desde móviles (mando) y TVs (visualizador). El objetivo es un core + apps modulares donde la comunidad pueda crear juegos, con precios bajos y workshop infinita. Filosofía: el core no interpreta, solo transporta; las apps deciden. N TVs y N móviles sin límite. El admin controla todo desde su móvil.
## Features actuales
### Core
- Servidor Node con Express + WebSocket en el mismo puerto (`/ws`).
- Detección de apps en `apps/`, cada una con `app.json`, `server.js`, `client/mobile.js`, `client/tv.js`, y sus HTML/CSS.
- Runtime de apps por worker hijo, con StateStore y EventStore.
- Gestión de jugadores con `player-store.js` (nombres por rol: móvil vs TV).
- Gestión de layout de apps por perfil (`app-layout-store.js`).
- Admin con rotación automática, sucesor designado, transferencia manual.
- `mainScreenId` para designar la TV principal.
- Settings globales (`maxPlayerNameLength`).
- Rutas HTTP: `/` (móvil), `/tv` (TV), `/a` (redirige a `/?admin=1`).
- `mobile.html` con header, menú de apps, modales de Room, mover, nueva carpeta, ajustes, renombrar.
- `tv.html` con header de jugadores, footer de TVs, vista de grid o árbol multi-columna.
- SDK con `connectPlatform`, `mountApp`, eventos.
- Gamepad API en móvil (siempre) y TV (opt-in por app con `gamepad.tv: true`).
- Sensores (acelerómetro, giroscopio, orientación, brújula) con permisos explícitos en iOS.
- Snapshots periódicos con anti-spam y keepalive.
- Reverse proxy con Caddy para HTTPS (necesario para sensores y gamepads en iOS).
- Borde blanco de la TV propia en el footer.
- Recalcular `isAdmin` en `players_update` y `player_left` del SDK.
- Botón ✕ de cerrar app visible solo para admin.
- Doble tap y zoom de página bloqueados en el paint con exclusión de controles interactivos.
- Pinch zoom del canvas del paint con fórmula correcta del punto focal.
- Canvas dinámico del paint: tamaño TV o tamaño admin, según disponibilidad y elección del admin.
### Apps existentes
- **`paint`**: lienzo colaborativo con zoom, undo, fill, timeline, backups, panel de admin, canvas dinámico.
- **`dice`**: lanzador de dados 3D con `@3d-dice/dice-box`. Pendiente el bug de múltiples TVs.
- **`maps`**: rutas con Leaflet.
- **`gamepad-tester`**: muestra el estado de los mandos en TV y móvil.
- **`sensor-tester`**: muestra el estado de los sensores en TV y móvil.
- **`plantilla`**: contador simple, sirve de plantilla para nuevas apps.
- **`core-showcase`**: app de demostración del core.
## Features restantes
### 1. Room: separar TVs y jugadores
- Modificar `renderRoomList()` del `mobile.html`.
- Dos bloques: "TVs" arriba, "Jugadores" abajo.
- Separados con un `<hr>` o similar.
- El admin ya puede designar la TV principal desde ahí (usa `set_main_screen` del core, solo falta el botón).
### 2. Transferir admin desde Room
- Botón `⭐` a la izquierda de cada jugador, visible solo para el admin actual, solo para móviles, no para el propio admin.
- Al pulsarlo, envía `transfer_admin` con `targetProfileId`.
- El servidor ya tiene el handler, solo falta el botón y el CSS.
### 3. "Quiero jugar a esto" desde los no-admin
- Un no-admin mantiene pulsada una app en su lista durante X ms.
- Se envía `want_app` con `{ appId }`.
- Al soltar, se envía `no_want_app`.
- El core mantiene un `Map<profileId, appId>` de deseos activos.
- Broadcast a todos los clientes.
- En el móvil del admin y en la TV, la app en cuestión brilla / parpadea / muestra el nombre del jugador.
- Si el admin lanza la app deseada, se limpian los deseos para esa app.
### 4. Bug del dado: múltiples TVs con dados distintos
- **Objetivo**: la TV principal (mainScreenId) ejecuta la física de `DiceBox`. Las TVs secundarias ven el mismo canvas exactamente, escalado.
- **Tecnología**: WebRTC.
- **Flujo**:
- TV principal hace `canvas.captureStream()`.
- Servidor del core actúa como servidor de señalización (`offer`, `answer`, `ice-candidate`).
- Cada TV secundaria pide el stream por WebRTC.
- Vídeo P2P entre principal y cada secundaria.
- Escalado con `object-fit: contain` (bandas negras donde no encaje).
- **Detección de rol**: la app `dice` mira `mainScreenId` para saber si es principal o secundaria.
- **Cambio de principal**: si la principal se desconecta, `assignMainScreen()` elige otra. La app `dice`:
- Manda "recoger dados" al jugador que los tiró.
- Destruye la conexión WebRTC antigua.
- La nueva principal inicializa `DiceBox`.
- Las secundarias se reconectan a la nueva principal.
- **Resultado al servidor**: la principal envía los resultados de la física al servidor, que los reenvía a los móviles (para mostrar los números en las fichas) y a las secundarias.
- **Admin TV**: el admin puede designar la TV principal desde Room (ya existe `set_main_screen`).
- **Complejidad**: alta. Días de trabajo.
### 5. Instalador con HTTPS automático
- Para el usuario final, que no sepa configurar Caddy.
- Opciones: mkcert embebido + QR para instalar la CA en el móvil, o DDNS + Let's Encrypt, o túnel cloudflared.
- Requisito para sensores y gamepads en iOS.
### 6. Persistencia del layout por perfil
- Ya existe `app-layout-store.js` con `app-layout.json` por perfil.
- Falta probar a fondo que un no-admin reorganiza sus apps y se le guarda correctamente.
- El usuario ha dicho: "lo de layout es de aplicación, no de perfil". Aclarar si quiere cambiar el modelo.
### 7. Juegos completos
- Es la parte grande. Con todo lo que hay (touch, gamepad, sensores, N móviles, N TVs), se puede hacer cualquier cosa.
- Ideas del usuario: Jackbox-style, DnD helper, herramientas, clásicos, emuladores (NDS, GBA, DOOM), party games rotativos, juegos por equipos con varias TVs.
### 8. Workshop
- Mercado de apps creadas por la comunidad.
- El sistema de apps modulares ya está preparado.
- Falta la tienda, buscador, o listado curado.
### 9. Mejoras menores pendientes
- Varios tapetes en `dice` (opcional, ambicioso).
- Probar el sensor-tester a fondo.
- Probar `gamepad-tester` en iOS.
- Convertir `paint` en una app que funcione sin TV (ya hecho).
- Documentación para la comunidad.
## Notas y decisiones
- El nombre del perfil y el nombre por rol están separados (`names: { mobile, tv }`).
- Los perfiles son persistentes por cookie `coreProfileId`.
- El admin es un `profileId`, no un `connectionId`.
- Los eventos `gamepad_message` y `sensor_message` se reenvían a la app activa como `app_message` con `type` propio.
- El render del canvas en el paint usa `image-rendering: pixelated`.
- El bloqueo del doble tap en el paint excluye botones, inputs, selects, textareas y `[role=button]`.
- El servidor tiene rutas para `/a` que redirige a `/?admin=1`. El `mobile.html` lee el query param, limpia la URL y pasa `forceAdmin: true` al SDK.
- El admin entrante por `/a` sustituye al admin actual. Sin persistencia.
- La TV no tiene input propio. Todo se hace desde el móvil del admin.
- La arquitectura permite N TVs y N móviles sin límite.
- El código del core está limpio de logs de diagnóstico (se han quitado los que había para el debug del paint). .\app-layout-store.js 7762 caracteres const fs = require("fs");
const path = require("path");
class AppLayoutStore {
constructor(options = {}) {
this.baseDir = options.baseDir || path.join(__dirname, "data", "players");
this.cache = new Map();
}
getFilePath(profileId) {
return path.join(this.baseDir, profileId, "app-layout.json");
}
ensureProfileDirectory(profileId) {
const dir = path.dirname(this.getFilePath(profileId));
fs.mkdirSync(dir, {
recursive: true,
});
}
/*
* Returns the user's layout reconciled against
* the apps that are currently installed.
*
* Installed apps are ALWAYS present.
*
* - Existing app positions are preserved.
* - New apps are added to root.
* - Uninstalled apps are removed.
*/
get(profileId, apps = []) {
if (!profileId || typeof profileId !== "string") {
throw new TypeError("profileId is required");
}
const installedApps = Array.isArray(apps) ? apps : [];
let layout = null;
/*
* Cache
*/
if (this.cache.has(profileId)) {
layout = clone(this.cache.get(profileId));
} else {
const filePath = this.getFilePath(profileId);
/*
* First use: no saved layout yet.
*/
if (!fs.existsSync(filePath)) {
layout = createDefaultLayout(installedApps);
} else {
try {
layout = JSON.parse(fs.readFileSync(filePath, "utf8"));
layout = normalizeLayout(layout);
} catch (err) {
console.error(
`[AppLayoutStore] Invalid layout for "${profileId}":`,
err,
);
layout = createDefaultLayout(installedApps);
}
}
}
/*
* Reconcile with the current app registry.
*/
layout = reconcileInstalledApps(layout, installedApps);
this.cache.set(profileId, clone(layout));
/*
* If reconciliation changed the layout,
* persist it immediately.
*/
this.ensureProfileDirectory(profileId);
const filePath = this.getFilePath(profileId);
try {
writeAtomically(filePath, layout);
} catch (err) {
console.error(
`[AppLayoutStore] Could not persist layout for "${profileId}":`,
err,
);
}
return clone(layout);
}
validate(layout) {
return normalizeLayout(layout);
}
/*
* Save a layout.
*
* apps is intentionally accepted here so the server
* can guarantee that ALL installed apps remain present,
* even if an old client sends an incomplete layout.
*/
save(profileId, layout, apps = []) {
if (!profileId || typeof profileId !== "string") {
throw new TypeError("profileId is required");
}
let normalized = normalizeLayout(layout);
normalized = reconcileInstalledApps(
normalized,
Array.isArray(apps) ? apps : [],
);
this.ensureProfileDirectory(profileId);
const filePath = this.getFilePath(profileId);
writeAtomically(filePath, normalized);
this.cache.set(profileId, clone(normalized));
return clone(normalized);
}
}
/* ============================================================
DEFAULT LAYOUT
============================================================ */
function createDefaultLayout(apps) {
return {
version: 1,
root: Array.isArray(apps)
? apps.map((app) => ({
type: "app",
id: app.id,
}))
: [],
};
}
/* ============================================================
NORMALIZATION
============================================================ */
function normalizeLayout(layout) {
if (!layout || typeof layout !== "object") {
throw new Error("Invalid app layout");
}
if (layout.version !== 1) {
throw new Error("Unsupported app layout version");
}
if (!Array.isArray(layout.root)) {
throw new Error("App layout root must be an array");
}
const folderIds = new Set();
const appIds = new Set();
const root = normalizeItems(layout.root, folderIds, appIds);
return {
version: 1,
root,
};
}
function normalizeItems(items, folderIds, appIds) {
if (!Array.isArray(items)) {
throw new Error("Layout items must be an array");
}
return items.map((item) => {
if (!item || typeof item !== "object") {
throw new Error("Invalid layout item");
}
if (item.type === "app") {
if (typeof item.id !== "string" || !item.id.trim()) {
throw new Error("App item requires an id");
}
if (appIds.has(item.id)) {
throw new Error(`Duplicate app "${item.id}" in layout`);
}
appIds.add(item.id);
return {
type: "app",
id: item.id,
};
}
if (item.type === "folder") {
if (typeof item.id !== "string" || !item.id.trim()) {
throw new Error("Folder requires an id");
}
if (typeof item.name !== "string" || !item.name.trim()) {
throw new Error("Folder requires a name");
}
if (folderIds.has(item.id)) {
throw new Error(`Duplicate folder "${item.id}" in layout`);
}
folderIds.add(item.id);
return {
type: "folder",
id: item.id,
name: item.name.trim(),
children: normalizeItems(item.children || [], folderIds, appIds),
};
}
throw new Error(`Unknown layout item type "${item.type}"`);
});
}
/* ============================================================
INSTALLED APP RECONCILIATION
============================================================ */
function reconcileInstalledApps(layout, apps) {
const installedApps = Array.isArray(apps) ? apps : [];
const installedIds = new Set(
installedApps
.map((app) => app && app.id)
.filter((id) => typeof id === "string" && id.trim()),
);
/*
* Remove apps that are no longer installed.
*
* Folders are preserved.
*/
function cleanItems(items) {
const result = [];
for (const item of items) {
if (item.type === "app") {
if (installedIds.has(item.id)) {
result.push(item);
}
continue;
}
if (item.type === "folder") {
item.children = cleanItems(item.children || []);
result.push(item);
}
}
return result;
}
layout.root = cleanItems(layout.root);
/*
* Find every app already represented
* somewhere in the tree.
*/
const representedAppIds = new Set();
function collectAppIds(items) {
for (const item of items) {
if (item.type === "app") {
representedAppIds.add(item.id);
continue;
}
if (item.type === "folder") {
collectAppIds(item.children || []);
}
}
}
collectAppIds(layout.root);
/*
* Any installed app that is not already
* represented gets added to the root.
*
* Registry order is preserved.
*/
for (const app of installedApps) {
if (!app || typeof app.id !== "string" || !app.id.trim()) {
continue;
}
if (representedAppIds.has(app.id)) {
continue;
}
layout.root.push({
type: "app",
id: app.id,
});
representedAppIds.add(app.id);
}
return layout;
}
/* ============================================================
PERSISTENCE
============================================================ */
function writeAtomically(filePath, value) {
const tempPath = filePath + ".tmp";
fs.writeFileSync(tempPath, JSON.stringify(value, null, 4), "utf8");
fs.renameSync(tempPath, filePath);
}
/* ============================================================
CLONE
============================================================ */
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
module.exports = {
AppLayoutStore,
};
.\apps\dice\client\mobile\mobile.css 7823 caracteres /* ============================================================
DICE MOBILE - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire phone screen */
.mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
/* Top toolbar */
.mobile .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none;
min-height: 34px;
}
/* Content area */
.mobile .content {
flex: 1;
overflow: hidden;
position: relative;
touch-action: none;
}
/* Bottom footer */
.mobile .footer {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
touch-action: none;
min-height: 50px;
gap: 10px;
}
/* Main dice app container */
.dice-app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* ============================================================
DICE SELECTOR GRID
============================================================ */
.dice-selector {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 8px;
}
/* Individual dice type button */
.dice-option {
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #e0e0e0;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all 0.2s;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* Press effect on dice option */
.dice-option:active {
background: rgba(255, 255, 255, 0.15);
transform: scale(0.95);
}
/* ============================================================
HAND AREA
============================================================ */
.hand-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
min-height: 0;
overflow-y: auto;
}
/* Hand section headers */
.hand-header {
display: flex;
justify-content: space-between;
padding: 0 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
/* Dice hand container */
.dice-hand {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
overflow: hidden;
position: relative;
touch-action: none;
min-height: 80px;
}
/* Individual dice item */
.dice-item {
width: 70px;
height: 70px;
border: 2px solid transparent;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: none;
background: rgba(255, 255, 255, 0.05);
}
/* Selected dice item */
.dice-item.selected {
border-color: #ffd700;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
transform: scale(1.05);
}
/* Dice icon */
.dice-item .dice-icon {
font-size: 28px;
pointer-events: none;
}
/* Dice type label */
.dice-item .dice-type {
font-size: 11px;
font-weight: bold;
margin-top: 2px;
pointer-events: none;
}
/* Dice result */
.dice-item .dice-result {
font-size: 10px;
color: #ffd700;
margin-top: 2px;
pointer-events: none;
}
/* Empty state message */
.empty-state {
width: 100%;
text-align: center;
color: rgba(255, 255, 255, 0.4);
padding: 20px;
font-style: italic;
font-size: 14px;
pointer-events: none;
}
/* ============================================================
SETTINGS BUTTON
============================================================ */
.settings-btn {
padding: 4px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
touch-action: none;
}
/* ============================================================
FOOTER BUTTONS
============================================================ */
.footer-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
transition: all 0.2s;
}
/* Press effect on footer buttons */
.footer-btn:active {
transform: scale(0.95);
}
/* Pick up button */
.btn-recoger {
background: rgba(167, 139, 250, 0.2);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.3);
}
/* Roll button */
.btn-lanzar {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
border: 1px solid rgba(76, 175, 80, 0.3);
}
/* Delete button */
.btn-eliminar {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
border: 1px solid rgba(244, 67, 54, 0.3);
}
/* ============================================================
SETTINGS OVERLAY
============================================================ */
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
touch-action: none;
display: none;
}
.settings-overlay.visible {
display: flex;
}
/* Settings panel */
.settings-panel {
background: rgba(20, 24, 40, 0.96);
backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 20px 24px;
width: 90%;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
touch-action: none;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.9);
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
/* Panel header */
.settings-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.settings-panel .panel-header h2 {
font-size: 14px;
font-weight: 600;
color: #fbbf24;
}
/* Close button */
.settings-panel .close-btn {
background: rgba(255, 255, 255, 0.04);
border: none;
color: rgba(255, 255, 255, 0.2);
font-size: 16px;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
}
/* Panel content */
.settings-panel .panel-content {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Setting section */
.settings-panel .setting-section {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Section label */
.settings-panel .section-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Slider container */
.settings-panel .slider-container {
display: flex;
align-items: center;
gap: 8px;
}
.settings-panel .slider-container input[type="range"] {
flex: 1;
accent-color: #a78bfa;
height: 4px;
cursor: pointer;
}
/* Slider value display */
.settings-panel .slider-value {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
min-width: 35px;
text-align: right;
}
/* Color picker */
.settings-panel input[type="color"] {
width: 100%;
height: 40px;
border: none;
border-radius: 8px;
cursor: pointer;
background: none;
padding: 0;
} .\player-store.js 8112 caracteres const fs = require("fs");
const path = require("path");
class PlayerStore {
constructor(options = {}) {
this.baseDir = path.resolve(
options.baseDir || path.join(__dirname, "data", "players"),
);
fs.mkdirSync(this.baseDir, { recursive: true });
}
has(profileId) {
return fs.existsSync(this.getPath(profileId));
}
get(profileId) {
const filePath = this.getPath(profileId);
if (!fs.existsSync(filePath)) {
return null;
}
try {
return clone(JSON.parse(fs.readFileSync(filePath, "utf8")));
} catch (err) {
throw new Error(`Could not read profile "${profileId}": ${err.message}`);
}
}
create(profileId, data = {}) {
if (this.has(profileId)) {
return this.get(profileId);
}
const now = new Date().toISOString();
const defaultName =
typeof data.name === "string" && data.name.trim()
? data.name.trim()
: "Player";
// ------------------------------------------------------------
// Nombres por rol
// ------------------------------------------------------------
//
// Un mismo perfil puede estar conectado a la vez como móvil
// y como TV (mismo navegador, mismo `profileId`). Cada rol
// tiene su propio nombre visible, para que renombrar la TV
// no afecte al móvil del mismo dispositivo y viceversa.
//
// `name` se conserva como nombre por defecto / fallback.
// `names` guarda el nombre específico de cada rol.
// ------------------------------------------------------------
const names = {};
if (data.names && typeof data.names === "object") {
if (typeof data.names.mobile === "string" && data.names.mobile.trim()) {
names.mobile = data.names.mobile.trim();
}
if (typeof data.names.tv === "string" && data.names.tv.trim()) {
names.tv = data.names.tv.trim();
}
}
const profile = {
id: profileId,
name: defaultName,
names,
created: now,
lastSeen: now,
};
this.save(profileId, profile);
return clone(profile);
}
update(profileId, updater) {
if (typeof updater !== "function") {
throw new TypeError("PlayerStore.update requires a function");
}
const current = this.get(profileId) || this.create(profileId);
const next = updater(clone(current));
if (next === undefined) {
throw new TypeError("PlayerStore updater must return the next profile");
}
this.save(profileId, next);
return clone(next);
}
touch(profileId) {
return this.update(profileId, (profile) => {
profile.lastSeen = new Date().toISOString();
return profile;
});
}
// ------------------------------------------------------------
// Nombre por defecto
// ------------------------------------------------------------
setName(profileId, name) {
if (typeof name !== "string" || !name.trim()) {
throw new TypeError("Player name must be a non-empty string");
}
return this.update(profileId, (profile) => {
profile.name = name.trim();
return profile;
});
}
// ------------------------------------------------------------
// Nombre por rol
// ------------------------------------------------------------
getName(profileId, role) {
const profile = this.get(profileId);
if (!profile) return null;
if (
profile.names &&
typeof profile.names[role] === "string" &&
profile.names[role].trim()
) {
return profile.names[role];
}
// Fallback: nombre por defecto del perfil.
return profile.name || null;
}
setNameForRole(profileId, role, name) {
if (typeof name !== "string" || !name.trim()) {
throw new TypeError("Player name must be a non-empty string");
}
if (role !== "mobile" && role !== "tv") {
throw new TypeError(`Invalid role: ${role}`);
}
return this.update(profileId, (profile) => {
if (!profile.names || typeof profile.names !== "object") {
profile.names = {};
}
profile.names[role] = name.trim();
// Mantenemos `name` como "último nombre usado", por si acaso
// una app o el propio core lo consulta como fallback global.
profile.name = name.trim();
return profile;
});
}
save(profileId, data) {
assertSerializable(data);
const filePath = this.getPath(profileId);
atomicWrite(filePath, data);
return clone(data);
}
getPath(profileId) {
validateProfileId(profileId);
return path.join(this.baseDir, profileId, "profile.json");
}
// ============================================================
// NOMBRES POR (profileId, role)
// ============================================================
//
// El `profile.name` es un nombre "de identidad persistente",
// útil como fallback. Pero un mismo perfil puede tener varias
// conexiones simultáneas con roles distintos (por ejemplo,
// abrir mobile y TV en la misma máquina, que comparten cookie).
//
// Para que el nombre visible del móvil y de la TV sean
// independientes, guardamos un nombre por (profileId, role)
// en `names.json`. El `player.name` se inicializa desde ahí
// cuando existe, y desde `profile.name` cuando no.
//
// Al cambiar el nombre con `set_name`, solo se actualiza la
// entrada (profileId, role), no el `profile.name` completo.
// Así un cambio en el móvil no pisa el nombre de la TV.
// ============================================================
getRoleName(profileId, role) {
if (!isValidRole(role)) {
return null;
}
const data = this._readNames(profileId);
if (!data) {
return null;
}
const value = data[role];
return typeof value === "string" && value.trim() ? value : null;
}
setRoleName(profileId, role, name) {
if (!isValidRole(role)) {
throw new TypeError("Invalid role");
}
if (typeof name !== "string" || !name.trim()) {
throw new TypeError("Player name must be a non-empty string");
}
const data = this._readNames(profileId) || {};
data[role] = name.trim();
this._writeNames(profileId, data);
return clone(data[role]);
}
clearRoleName(profileId, role) {
if (!isValidRole(role)) {
return false;
}
const data = this._readNames(profileId);
if (!data || !(role in data)) {
return false;
}
delete data[role];
this._writeNames(profileId, data);
return true;
}
getNamesPath(profileId) {
validateProfileId(profileId);
return path.join(this.baseDir, profileId, "names.json");
}
_readNames(profileId) {
const filePath = this.getNamesPath(profileId);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
return data;
} catch (_) {
return null;
}
}
_writeNames(profileId, data) {
const filePath = this.getNamesPath(profileId);
atomicWrite(filePath, data);
}
}
function isValidRole(role) {
return role === "mobile" || role === "tv";
}
function validateProfileId(profileId) {
if (
typeof profileId !== "string" ||
!profileId.trim() ||
profileId.includes("/") ||
profileId.includes("\\") ||
profileId.includes("..")
) {
throw new TypeError("Invalid profileId");
}
}
function assertSerializable(value) {
try {
structuredClone(value);
} catch (err) {
throw new TypeError(`Data must be structured-cloneable: ${err.message}`);
}
}
function clone(value) {
if (value === undefined) {
return undefined;
}
return structuredClone(value);
}
function atomicWrite(filePath, data) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(temporaryPath, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(temporaryPath, filePath);
}
module.exports = {
PlayerStore,
}; .\apps\gamepad-tester\client\mobile.js 8735 caracteres export function mount(container, sdk) {
let destroyed = false;
let htmlReady = false;
let statusEl = null;
let gamepadListEl = null;
let serverViewEl = null;
// Map: gamepadId -> { el, buttons: [el], axes: [{ fill, value }] }
const renderedGamepads = new Map();
// Última firma del "shape" (mandos + botones + ejes por mando).
// Si cambia, reconstruimos la lista. Si no, solo actualizamos
// valores en el DOM existente.
let lastGamepadsSignature = "";
const unsubscribe = sdk.onApp((data) => {
if (!htmlReady) return;
if (data.type === "snapshots") {
renderServerView(data.snapshots || []);
}
});
let localRenderTimer = null;
// ============================================================
// RENDER PRINCIPAL
// ============================================================
function renderLocalViewFromSnapshot() {
if (destroyed) return;
if (!statusEl || !gamepadListEl) return;
let gamepads = [];
try {
gamepads = sdk.getSnapshot() || [];
} catch (_) {
gamepads = [];
}
const shapeSignature = gamepads
.map((gp) => `${gp.id}:${gp.buttons.length}:${gp.axes.length}`)
.join("|");
// Si el shape cambió, reconstruimos la lista entera.
if (shapeSignature !== lastGamepadsSignature) {
lastGamepadsSignature = shapeSignature;
if (gamepads.length === 0) {
statusEl.innerHTML = `
<div class="status status-empty">
<div class="status-icon">🎮</div>
<div class="status-title">No hay mandos conectados</div>
<div class="status-hint">
Empareja un mando por Bluetooth con este dispositivo.
Si ya lo has hecho, pulsa cualquier botón para que el
navegador lo detecte.
</div>
</div>
`;
gamepadListEl.innerHTML = "";
renderedGamepads.clear();
return;
}
statusEl.innerHTML = `
<div class="status status-ok">
<div class="status-icon">✅</div>
<div class="status-title">
${gamepads.length} mando${gamepads.length === 1 ? "" : "s"} conectado${gamepads.length === 1 ? "" : "s"}
</div>
<div class="status-hint">
Los eventos se envían al servidor en tiempo real.
</div>
</div>
`;
gamepadListEl.innerHTML = "";
renderedGamepads.clear();
for (const gp of gamepads) {
const card = createGamepadCard(gp);
gamepadListEl.appendChild(card.el);
renderedGamepads.set(gp.id, card);
}
updateGamepadsValues(gamepads);
return;
}
// Shape igual: solo actualizamos valores.
if (gamepads.length === 0) return;
updateGamepadsValues(gamepads);
}
// ============================================================
// CREAR CARD DE UN MANDO
// ============================================================
function createGamepadCard(gp) {
const card = document.createElement("div");
card.className = "gamepad-card";
// Nombre
const nameEl = document.createElement("div");
nameEl.className = "gamepad-name";
nameEl.textContent = gp.name;
card.appendChild(nameEl);
// Stats
const statsEl = document.createElement("div");
statsEl.className = "gamepad-stats";
statsEl.innerHTML = `
<span>${gp.buttons.length} botones</span>
<span>${gp.axes.length} ejes</span>
<span class="mapping">${gp.mapping === "standard" ? "estándar" : "no estándar"}</span>
`;
card.appendChild(statsEl);
// Botones
const buttonsEl = document.createElement("div");
buttonsEl.className = "mini-buttons";
const buttonEls = [];
for (let i = 0; i < gp.buttons.length; i++) {
const btn = document.createElement("div");
btn.className = "mini-button";
btn.textContent = String(i);
buttonsEl.appendChild(btn);
buttonEls.push(btn);
}
card.appendChild(buttonsEl);
// Ejes
const axesEl = document.createElement("div");
axesEl.className = "mini-axes";
const axisEls = [];
for (let i = 0; i < gp.axes.length; i++) {
const axis = document.createElement("div");
axis.className = "mini-axis";
const label = document.createElement("span");
label.className = "axis-label";
label.textContent = String(i);
const bar = document.createElement("div");
bar.className = "axis-bar";
const fill = document.createElement("div");
fill.className = "axis-fill";
bar.appendChild(fill);
const center = document.createElement("div");
center.className = "axis-center";
bar.appendChild(center);
const value = document.createElement("span");
value.className = "axis-value";
value.textContent = "0.00";
axis.appendChild(label);
axis.appendChild(bar);
axis.appendChild(value);
axesEl.appendChild(axis);
axisEls.push({ fill, value });
}
card.appendChild(axesEl);
return {
el: card,
buttons: buttonEls,
axes: axisEls,
};
}
// ============================================================
// ACTUALIZAR VALORES
// ============================================================
function updateGamepadsValues(gamepads) {
for (const gp of gamepads) {
const card = renderedGamepads.get(gp.id);
if (!card) continue;
// Botones
for (let i = 0; i < gp.buttons.length; i++) {
const btn = gp.buttons[i];
const el = card.buttons[i];
if (!el) continue;
const pressed = !!(btn.pressed || btn.touched);
if (pressed !== el.classList.contains("pressed")) {
el.classList.toggle("pressed", pressed);
}
}
// Ejes
for (let i = 0; i < gp.axes.length; i++) {
const el = card.axes[i];
if (!el) continue;
const val = gp.axes[i];
const text = val.toFixed(2);
if (el.value.textContent !== text) {
el.value.textContent = text;
}
const percent = Math.round(((val + 1) / 2) * 100);
const width = `${percent}%`;
if (el.fill.style.width !== width) {
el.fill.style.width = width;
}
}
}
}
// ============================================================
// SERVER VIEW
// ============================================================
function renderServerView(snapshots) {
if (!serverViewEl) return;
const serverCount = snapshots.reduce(
(acc, s) => acc + (s.gamepads?.length || 0),
0,
);
const content = `${serverCount} mando${serverCount === 1 ? "" : "s"} en ${snapshots.length} dispositivo${snapshots.length === 1 ? "" : "s"}`;
if (serverViewEl.textContent !== content) {
serverViewEl.textContent = content;
}
}
// ============================================================
// CARGA
// ============================================================
const cssId = "gamepad-tester-mobile-style";
if (!document.getElementById(cssId)) {
const link = document.createElement("link");
link.id = cssId;
link.rel = "stylesheet";
link.href = `/apps/gamepad-tester/client/mobile/mobile.css`;
document.head.appendChild(link);
}
fetch(`/apps/gamepad-tester/client/mobile/mobile.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
statusEl = container.querySelector("#status");
gamepadListEl = container.querySelector("#gamepad-list");
const serverViewWrapper = container.querySelector("#server-view");
if (serverViewWrapper) {
serverViewWrapper.className = "server-view";
serverViewWrapper.innerHTML = `
<div class="server-view-title">👁️ Servidor ve:</div>
<div class="server-view-content"></div>
`;
serverViewEl = serverViewWrapper.querySelector(
".server-view-content",
);
}
htmlReady = true;
renderLocalViewFromSnapshot();
localRenderTimer = setInterval(() => {
renderLocalViewFromSnapshot();
}, 50);
})
.catch((err) => {
console.error("[gamepad-tester/mobile] Error cargando HTML:", err);
});
// ============================================================
// UNMOUNT
// ============================================================
return function unmount() {
destroyed = true;
if (localRenderTimer) {
clearInterval(localRenderTimer);
localRenderTimer = null;
}
if (typeof unsubscribe === "function") {
unsubscribe();
}
renderedGamepads.clear();
};
} .\apps\dice\server.js 11570 caracteres // Game state on the server.
let diceOnTable = [];
let playerColors = {};
let diceCounter = 0;
let lastResults = [];
const availableColors = [
'#ff6b6b',
'#4ecdc4',
'#45b7d1',
'#96ceb4',
'#ffeaa7',
'#dfe6e9',
'#ff9ff3',
'#feca57'
];
function assignPlayerColor(
playerId
) {
if (
!playerColors[playerId]
) {
const index =
Object.keys(
playerColors
).length %
availableColors.length;
playerColors[playerId] =
availableColors[index];
}
return playerColors[playerId];
}
function generateRandomPosition() {
return {
x:
(Math.random() - 0.5) * 8,
z:
(Math.random() - 0.5) * 6
};
}
function generateResult(
diceType
) {
const faces = {
d3: 3,
d4: 4,
d6: 6,
d8: 8,
d10: 10,
d100: 100,
d12: 12,
d20: 20
};
const maxFaces =
faces[diceType] || 6;
let result =
Math.floor(
Math.random() *
maxFaces
) + 1;
if (
diceType === 'd100'
) {
const tens =
Math.floor(
Math.random() * 10
);
result =
tens === 0
? 100
: tens * 10;
}
return result;
}
// ============================================================
// APP
// ============================================================
module.exports =
function createApp(ctx) {
return {
// ====================================================
// START
// ====================================================
async onStart() {
diceOnTable = [];
playerColors = {};
diceCounter = 0;
lastResults = [];
ctx.log(
'Dice app started'
);
ctx.broadcast({
type:
'initial_state',
diceOnTable:
diceOnTable,
message:
'Clean table! Roll your dice'
});
},
// ====================================================
// PLAYER JOINED
// ====================================================
async onPlayerJoined(
player
) {
ctx.log(
`Player joined: ${player.name} (${player.id})`
);
ctx.sendTo(
player.id,
{
type:
'initial_state',
diceOnTable:
diceOnTable,
message:
'Dice table state'
}
);
},
// ====================================================
// PLAYER CONNECTED
// ====================================================
async onPlayerConnected(
player
) {
ctx.log(
`Player connected: ${player.name} (${player.id})`
);
// Send the current table state after
// a profile/connection recovery.
ctx.sendTo(
player.id,
{
type:
'initial_state',
diceOnTable:
diceOnTable,
message:
'Dice table state'
}
);
},
// ====================================================
// PLAYER DISCONNECTED
// ====================================================
async onPlayerDisconnected(
player
) {
ctx.log(
`Player disconnected: ${player.name} (${player.id})`
);
},
// ====================================================
// PLAYER LEFT
// ====================================================
async onPlayerLeft(
player
) {
ctx.log(
`Player left: ${player.name} (${player.id})`
);
},
// ====================================================
// MESSAGES
// ====================================================
async onMessage(
data,
player
) {
const playerId =
player.id;
const playerColor =
assignPlayerColor(
playerId
);
switch (
data.type
) {
// ============================================
// ROLL DICE
// ============================================
case 'roll_dice': {
const rolledDice =
(
data.dice || []
).map(
die => {
diceCounter++;
return {
id:
`die_${diceCounter}_${Date.now()}`,
diceType:
die.diceType,
color:
data.configuration?.color ||
playerColor,
playerId:
playerId,
position:
generateRandomPosition(),
result:
generateResult(
die.diceType
),
onTable:
true,
timestamp:
Date.now()
};
}
);
diceOnTable.push(
...rolledDice
);
ctx.broadcast({
type:
'dice_rolled',
dice:
rolledDice,
playerId:
playerId,
totalDiceOnTable:
diceOnTable.length
});
return;
}
// ============================================
// TABLE RESULTS
// ============================================
case 'table_results': {
lastResults =
data.results ||
[];
ctx.broadcast({
type:
'table_results',
results:
lastResults,
totalSum:
data.totalSum ||
0
});
return;
}
// ============================================
// CHANGE CONFIGURATION
// ============================================
case 'change_configuration': {
ctx.broadcast({
type:
'configuration_updated',
playerId:
playerId,
configuration:
data.configuration
});
return;
}
// ============================================
// REROLL DICE
// ============================================
case 'reroll_dice': {
ctx.broadcast({
type:
'reroll_dice',
playerId:
playerId,
diceTypes:
data.diceTypes ||
['d6'],
configuration:
data.configuration ||
null
});
return;
}
// ============================================
// REMOVE TABLE DICE
// ============================================
case 'remove_table_dice': {
const diceIds =
data.diceIds ||
[];
// Keep server state synchronized
// with the dice removed by the client.
diceOnTable =
diceOnTable.filter(
die =>
!diceIds.includes(
die.id
)
);
ctx.broadcast({
type:
'remove_table_dice',
playerId:
playerId,
diceIds:
diceIds
});
return;
}
// ============================================
// CLEAR FULL TABLE
// ============================================
case 'clear_full_table': {
diceOnTable = [];
lastResults = [];
ctx.broadcast({
type:
'clear_full_table',
playerId:
playerId
});
return;
}
}
},
// ====================================================
// STOP
// ====================================================
async onStop() {
ctx.log(
'Dice app stopped'
);
}
};
}; .\apps\paint\client\mobile\mobile.css 11706 caracteres /* ============================================================
PAINT MOBILE - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire phone screen.
Bloquea cualquier gesto de zoom del navegador: doble tap,
pinch, y context menu de pulsación larga. */
.paint-mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
touch-action: none !important;
-webkit-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
-webkit-tap-highlight-color: transparent;
}
/* Hereda el bloqueo a todos los hijos. Los inputs lo necesitan
para poder recibir eventos sin que el navegador los intercepte. */
.paint-mobile * {
-webkit-user-select: none !important;
user-select: none !important;
-webkit-touch-callout: none !important;
-webkit-tap-highlight-color: transparent;
}
/* ============================================================
TOP TOOLBAR - DRAWING CONTROLS
============================================================ */
.paint-mobile .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none;
min-height: 34px;
}
.paint-mobile .toolbar label {
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
}
.paint-mobile .toolbar input[type="color"] {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
padding: 0;
cursor: pointer;
background: transparent;
touch-action: none;
}
.paint-mobile .toolbar input[type="range"] {
width: 45px;
accent-color: #a78bfa;
cursor: pointer;
touch-action: none;
}
.paint-mobile .toolbar .size-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
min-width: 18px;
text-align: center;
}
.paint-mobile .toolbar .zoom-info {
font-size: 9px;
color: rgba(255, 255, 255, 0.15);
margin-left: 2px;
}
.paint-mobile .toolbar .mode-toggle {
display: flex;
gap: 3px;
margin-left: 4px;
}
.paint-mobile .toolbar .mode-toggle button {
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
font-size: 9px;
touch-action: none;
}
.paint-mobile .toolbar .mode-toggle button.active {
border-color: rgba(167, 139, 250, 0.3);
color: #a78bfa;
background: rgba(167, 139, 250, 0.08);
}
.paint-mobile .toolbar .fill-btn,
.paint-mobile .toolbar .undo-btn {
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
font-size: 9px;
touch-action: none;
font-family: inherit;
}
.paint-mobile .toolbar .fill-btn.active {
border-color: rgba(52, 211, 153, 0.3);
color: #34d399;
background: rgba(52, 211, 153, 0.08);
}
.paint-mobile .toolbar .undo-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ============================================================
CANVAS AREA
============================================================ */
.paint-mobile .canvas-wrapper {
flex: 1;
overflow: hidden;
position: relative;
background: #181818;
touch-action: none !important;
-webkit-touch-callout: none !important;
}
.paint-mobile .canvas-wrapper .grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
z-index: 0;
}
.paint-mobile canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
background: #FFFFFF;
display: block;
touch-action: none !important;
cursor: crosshair;
z-index: 1;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
image-rendering: pixelated;
-webkit-touch-callout: none !important;
}
/* ============================================================
BOTTOM FOOTER
============================================================ */
.paint-mobile .footer {
padding: 4px 10px;
background: rgba(0, 0, 0, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
touch-action: none;
min-height: 32px;
}
.paint-mobile .footer .info {
font-size: 9px;
color: rgba(255, 255, 255, 0.15);
}
.paint-mobile .footer .zoom-controls {
display: flex;
gap: 3px;
align-items: center;
}
.paint-mobile .footer .zoom-controls button {
padding: 2px 8px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
font-size: 11px;
touch-action: none;
}
.paint-mobile .footer .zoom-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.15);
min-width: 32px;
text-align: center;
}
.paint-mobile .footer .admin-btn {
padding: 3px 12px;
border: 1px solid rgba(251, 191, 36, 0.1);
border-radius: 4px;
background: rgba(251, 191, 36, 0.08);
color: #fbbf24;
cursor: pointer;
font-size: 10px;
font-weight: 600;
touch-action: none;
}
.paint-mobile .footer .admin-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
}
/* ============================================================
ADMIN PANEL OVERLAY
============================================================ */
.paint-admin-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
touch-action: none;
}
.paint-admin-overlay.visible {
display: flex;
}
.paint-admin-panel {
background: rgba(20, 24, 40, 0.96);
backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 20px 24px;
width: 90%;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
touch-action: none;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.9);
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
.paint-admin-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.paint-admin-panel .panel-header h2 {
font-size: 14px;
font-weight: 600;
color: #fbbf24;
}
.paint-admin-panel .panel-header .close-btn {
background: rgba(255, 255, 255, 0.04);
border: none;
color: rgba(255, 255, 255, 0.2);
font-size: 16px;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
}
.paint-admin-panel .panel-tabs {
display: flex;
gap: 6px;
margin-bottom: 14px;
}
.paint-admin-panel .panel-tabs .tab-btn {
flex: 1;
padding: 8px 10px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.3);
touch-action: none;
}
.paint-admin-panel .panel-tabs .tab-btn.active {
background: rgba(167, 139, 250, 0.18);
color: #a78bfa;
}
.paint-admin-panel .tab-content {
display: none;
}
.paint-admin-panel .tab-content.active {
display: block;
}
.paint-admin-panel .panel-section {
margin-bottom: 14px;
}
.paint-admin-panel .section-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.2);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 1px;
}
.paint-admin-panel .btn-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.paint-admin-panel .btn-group button {
padding: 6px 14px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
touch-action: none;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.4);
}
.paint-admin-panel .btn-group button.primary {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.paint-admin-panel .btn-group button.danger {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
/* ============================================================
TRACKPAD (for moving the canvas on TV)
============================================================ */
.paint-admin-panel .trackpad {
width: 100%;
height: 80px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
touch-action: none;
cursor: grab;
overflow: hidden;
}
.paint-admin-panel .trackpad .dot {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(167, 139, 250, 0.3);
border: 2px solid rgba(167, 139, 250, 0.5);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.paint-admin-panel .trackpad .center-mark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 50%;
pointer-events: none;
}
.paint-admin-panel .trackpad .hint {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
font-size: 7px;
color: rgba(255, 255, 255, 0.05);
pointer-events: none;
white-space: nowrap;
}
/* ============================================================
PLAYERS LIST
============================================================ */
.paint-admin-panel .players-list-panel {
font-size: 10px;
color: rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.paint-admin-panel .player-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
}
.paint-admin-panel .player-item .name {
color: rgba(255, 255, 255, 0.5);
}
.paint-admin-panel .badge {
font-size: 8px;
padding: 1px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.15);
}
.paint-admin-panel .badge.admin-badge {
color: #fbbf24;
background: rgba(251, 191, 36, 0.08);
}
.paint-admin-panel .badge.viewer-badge {
color: #60a5fa;
background: rgba(96, 165, 250, 0.08);
}
/* ============================================================
TIMELINE CONTROLS
============================================================ */
.paint-admin-panel .timeline-controls {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 4px;
}
.paint-admin-panel .timeline-controls button {
padding: 3px 12px;
border: none;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
touch-action: none;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.paint-admin-panel .timeline-controls .info {
font-size: 9px;
color: rgba(255, 255, 255, 0.2);
min-width: 50px;
text-align: center;
}
.paint-timeline-slider {
width: 100%;
accent-color: #a78bfa;
height: 4px;
cursor: pointer;
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
} .\app-runtime.js 12842 caracteres const { fork } = require("child_process");
const path = require("path");
const { StateStore } = require("./state-store");
const { EventStore } = require("./event-store");
class AppRuntime {
constructor(options) {
if (!options || typeof options !== "object") {
throw new TypeError("AppRuntime requires options");
}
if (!options.app || !options.app.id) {
throw new Error("AppRuntime requires an app");
}
if (!options.serverPath) {
throw new Error("AppRuntime requires serverPath");
}
this.app = {
...options.app,
instanceId: options.instanceId || createInstanceId(),
};
this.serverPath = path.resolve(options.serverPath);
this.players = Array.isArray(options.players) ? clone(options.players) : [];
this.playerStore = options.playerStore || null;
this.appProfileStore = options.appProfileStore || null;
this.stateStore = new StateStore(options.initialState || {});
this.eventStore = new EventStore();
this.worker = null;
this.started = false;
this.stopping = false;
this.eventSubscriptions = new Map();
this.onBroadcast = options.onBroadcast || (() => {});
this.onSendTo = options.onSendTo || (() => {});
this.onLog = options.onLog || (() => {});
this.onError = options.onError || (() => {});
this.onStopped = options.onStopped || (() => {});
this.startPromise = null;
this.stopPromise = null;
this.requestCounter = 0;
this.pendingRequests = new Map();
}
get instanceId() {
return this.app.instanceId;
}
get state() {
return this.stateStore;
}
get events() {
return this.eventStore;
}
async start() {
if (this.started) {
return;
}
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = new Promise((resolve, reject) => {
const workerPath = path.join(__dirname, "app-runtime-worker.js");
this.worker = fork(workerPath, [], {
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
let settled = false;
const resolveStart = () => {
if (settled) {
return;
}
settled = true;
this.started = true;
this.stopping = false;
resolve();
};
const rejectStart = (err) => {
if (settled) {
return;
}
settled = true;
reject(err);
};
this.worker.on("message", (message) => {
this.handleWorkerMessage(message, {
resolveStart,
rejectStart,
});
});
this.worker.on("error", (err) => {
console.error(`[AppRuntime] Worker error for "${this.app.id}":`, err);
this.onError(err);
if (!this.started) {
rejectStart(err);
}
});
this.worker.on("exit", (code, signal) => {
const wasRunning = this.started && !this.stopping;
this.worker = null;
this.started = false;
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error("App runtime stopped"));
}
this.pendingRequests.clear();
if (wasRunning) {
const error = new Error(
`App "${this.app.id}" runtime exited` +
` (code=${code}, signal=${signal})`,
);
console.error(`[AppRuntime] ${error.message}`);
this.onError(error);
}
if (this.stopping) {
this.onStopped();
}
});
try {
this.worker.send({
type: "start",
app: clone(this.app),
serverPath: this.serverPath,
players: clone(this.players),
state: this.stateStore.get(),
stateRevision: this.stateStore.getRevision(),
});
} catch (err) {
rejectStart(err);
}
});
try {
await this.startPromise;
} finally {
this.startPromise = null;
}
}
async stop() {
if (this.stopPromise) {
return this.stopPromise;
}
if (!this.worker) {
this.started = false;
return;
}
this.stopping = true;
this.stopPromise = new Promise((resolve) => {
let resolved = false;
const finish = () => {
if (resolved) {
return;
}
resolved = true;
this.started = false;
this.worker = null;
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error("App runtime stopped"));
}
this.pendingRequests.clear();
resolve();
};
const timeout = setTimeout(() => {
if (!this.worker) {
finish();
return;
}
console.warn(`[AppRuntime] Force killing "${this.app.id}" runtime`);
try {
this.worker.kill();
} catch (_) {}
finish();
}, 5000);
this.onStopped = () => {
clearTimeout(timeout);
finish();
};
try {
this.worker.send({
type: "stop",
});
} catch (err) {
clearTimeout(timeout);
try {
this.worker.kill();
} catch (_) {}
finish();
}
});
try {
await this.stopPromise;
} finally {
this.stopPromise = null;
}
}
sendMessage(data, player) {
this.sendToWorker({
type: "message",
data: clone(data),
player: clone(player),
});
}
playerJoined(player) {
this.players = upsertPlayer(this.players, player);
this.sendToWorker({
type: "player_joined",
player: clone(player),
});
}
playerConnected(player) {
this.players = upsertPlayer(this.players, player);
this.sendToWorker({
type: "player_connected",
player: clone(player),
});
}
playerDisconnected(player) {
this.players = this.players.map((existing) =>
existing.id === player.id
? {
...existing,
connected: false,
}
: existing,
);
this.sendToWorker({
type: "player_disconnected",
player: clone(player),
});
}
playerLeft(player) {
this.players = this.players.filter((existing) => existing.id !== player.id);
this.sendToWorker({
type: "player_left",
player: clone(player),
});
}
updatePlayers(players) {
this.players = clone(players);
this.sendToWorker({
type: "players_update",
players: clone(this.players),
});
}
handleWorkerMessage(message, lifecycle) {
if (!message || typeof message !== "object") {
return;
}
switch (message.type) {
case "ready":
lifecycle.resolveStart();
return;
case "stopped":
this.onStopped();
return;
case "broadcast":
this.onBroadcast(clone(message.data));
return;
case "send_to":
this.onSendTo(message.playerId, clone(message.data));
return;
case "state_set":
try {
this.stateStore.set(message.state);
} catch (err) {
this.handleRuntimeError(err);
}
return;
case "event_emit":
this.handleEventEmit(message);
return;
case "event_history":
this.handleEventHistory(message);
return;
case "event_subscribe":
this.handleEventSubscribe(message.eventType);
return;
case "event_unsubscribe":
this.handleEventUnsubscribe(message.eventType);
return;
case "log":
this.onLog(...(Array.isArray(message.args) ? message.args : []));
return;
case "app_error": {
const error = new Error(message.message || "Unknown app error");
if (message.stack) {
error.stack = message.stack;
}
this.handleRuntimeError(error);
return;
}
case "profile_get":
this.handleProfileGet(message);
return;
case "profile_get_app_data":
this.handleProfileGetAppData(message);
return;
case "profile_set_app_data":
this.handleProfileSetAppData(message);
return;
default:
console.warn(`[AppRuntime] Unknown worker message "${message.type}"`);
}
}
handleEventEmit(message) {
try {
this.eventStore.emit(message.eventType, message.payload);
} catch (err) {
this.handleRuntimeError(err);
}
}
handleEventHistory(message) {
try {
const result = this.eventStore.history(message.options || {});
this.sendToWorker({
type: "event_history_result",
requestId: message.requestId,
result,
});
} catch (err) {
this.handleRuntimeError(err);
}
}
handleProfileGet(message) {
try {
if (!this.playerStore) {
throw new Error("PlayerStore is not configured");
}
const result = this.playerStore.get(message.profileId);
this.sendToWorker({
type: "profile_get_result",
requestId: message.requestId,
result,
});
} catch (err) {
this.sendToWorker({
type: "profile_get_result",
requestId: message.requestId,
error: err.message,
});
}
}
handleProfileGetAppData(message) {
try {
if (!this.appProfileStore) {
throw new Error("AppProfileStore is not configured");
}
const result = this.appProfileStore.get(message.profileId, message.appId);
this.sendToWorker({
type: "profile_get_app_data_result",
requestId: message.requestId,
result,
});
} catch (err) {
this.sendToWorker({
type: "profile_get_app_data_result",
requestId: message.requestId,
error: err.message,
});
}
}
handleProfileSetAppData(message) {
try {
if (!this.appProfileStore) {
throw new Error("AppProfileStore is not configured");
}
const result = this.appProfileStore.set(
message.profileId,
message.appId,
message.data,
);
this.sendToWorker({
type: "profile_set_app_data_result",
requestId: message.requestId,
result,
});
} catch (err) {
this.sendToWorker({
type: "profile_set_app_data_result",
requestId: message.requestId,
error: err.message,
});
}
}
handleEventSubscribe(eventType) {
if (!eventType) {
return;
}
if (this.eventSubscriptions.has(eventType)) {
return;
}
const unsubscribe = this.eventStore.on(eventType, (event) => {
this.sendEventToWorker(event);
});
this.eventSubscriptions.set(eventType, unsubscribe);
const history = this.eventStore.history({
type: eventType,
});
for (const event of history) {
this.sendEventToWorker(event);
}
}
handleEventUnsubscribe(eventType) {
const unsubscribe = this.eventSubscriptions.get(eventType);
if (!unsubscribe) {
return;
}
unsubscribe();
this.eventSubscriptions.delete(eventType);
}
sendEventToWorker(event) {
this.sendToWorker({
type: "event",
event: clone(event),
});
}
requestFromWorker(type, payload = {}) {
const requestId = `${Date.now()}-${++this.requestCounter}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, {
resolve,
reject,
});
const sent = this.sendToWorker({
type,
requestId,
...payload,
});
if (!sent) {
this.pendingRequests.delete(requestId);
reject(new Error("App runtime is not running"));
}
});
}
resolveWorkerRequest(requestId, result) {
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return;
}
this.pendingRequests.delete(requestId);
pending.resolve(clone(result));
}
handleRuntimeError(err) {
console.error(`[AppRuntime] Error in "${this.app.id}":`, err);
this.onError(err);
}
sendToWorker(message) {
if (!this.worker || !this.worker.connected) {
return false;
}
try {
this.worker.send(message);
return true;
} catch (err) {
this.handleRuntimeError(err);
return false;
}
}
}
function upsertPlayer(players, player) {
const index = players.findIndex((existing) => existing.id === player.id);
if (index === -1) {
return [...players, clone(player)];
}
const result = [...players];
result[index] = {
...result[index],
...clone(player),
};
return result;
}
function clone(value) {
if (value === undefined) {
return undefined;
}
return structuredClone(value);
}
function createInstanceId() {
return `${Date.now()}-` + Math.random().toString(36).slice(2, 10);
}
module.exports = {
AppRuntime,
};
.\apps\paint\client\tv.js 13135 caracteres // Paint - TV side. Same whiteboard as the original, but as a mounted
// module inside the platform page (without its own WebSocket).
//
// IMPORTANTE (compatibilidad con el core nuevo):
// - mount() NO es async. Carga el HTML con .then() y se suscribe a
// sdk.onApp() INMEDIATAMENTE, antes de que el core pueda recibir
// el broadcast inicial del servidor.
// - Devuelve la función de unmount de forma síncrona.
export function mount(container, sdk) {
let destroyed = false;
let htmlReady = false;
let pendingMessages = [];
// Referencias del DOM
let canvas = null;
let ctx = null;
let wrapper = null;
let zoomLabel = null;
let CANVAS_WIDTH = 1920;
let CANVAS_HEIGHT = 1080;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let globalPoints = [];
let currentIndex = -1;
// Checkpoints
let checkpoints = [];
const CHECKPOINT_INTERVAL = 120;
const MAX_CHECKPOINTS = 10;
let receivedInit = false;
// ============================================================
// SUSCRIPCIÓN INMEDIATA AL SDK
// ============================================================
const unsubscribe = sdk.onApp((data) => {
if (!data) return;
if (!htmlReady || !canvas) {
pendingMessages.push(data);
return;
}
handleMessage(data);
});
function handleMessage(data) {
if (data.type === 'paint_init') {
receivedInit = true;
globalPoints = data.events || [];
currentIndex = data.current || -1;
CANVAS_WIDTH = data.canvas.width || 1920;
CANVAS_HEIGHT = data.canvas.height || 1080;
offsetX = data.canvas.offsetX || 0;
offsetY = data.canvas.offsetY || 0;
scale = data.canvas.scale || 1;
initCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
clearCheckpoints();
renderUpTo(currentIndex);
}
if (data.type === 'new_point') {
const point = data.point;
checkpoints = checkpoints.filter((cp) => cp.index < data.current);
globalPoints.length = data.current;
globalPoints.push(point);
currentIndex = data.current;
applyEvent(point);
}
if (data.type === 'history_index') {
renderUpTo(data.index);
}
if (data.type === 'clear') {
globalPoints = [];
clearCheckpoints();
renderUpTo(-1);
}
if (data.type === 'history_replaced') {
globalPoints = data.events || [];
clearCheckpoints();
renderUpTo(
data.current !== undefined ? data.current : globalPoints.length - 1
);
}
if (data.type === 'canvas_moved') {
offsetX = data.offsetX;
offsetY = data.offsetY;
scale = data.scale;
updateTransform();
}
}
// ============================================================
// CARGA DEL HTML Y CSS
// ============================================================
const cssId = 'paint-tv-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/paint/client/tv/tv.css`;
document.head.appendChild(link);
}
fetch(`/apps/paint/client/tv/tv.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
canvas = container.querySelector('#canvas');
ctx = canvas.getContext('2d');
wrapper = container.querySelector('#canvasWrapper');
zoomLabel = container.querySelector('#zoomLabel');
htmlReady = true;
// Botones de zoom
const zoomOutBtn = container.querySelector('#zoomOutBtn');
const zoomResetBtn = container.querySelector('#zoomResetBtn');
const zoomInBtn = container.querySelector('#zoomInBtn');
if (zoomOutBtn) {
zoomOutBtn.onclick = () => applyCenteredZoom(Math.max(0.2, scale / 1.2));
}
if (zoomResetBtn) {
zoomResetBtn.onclick = () => applyCenteredZoom(1);
}
if (zoomInBtn) {
zoomInBtn.onclick = () => applyCenteredZoom(Math.min(3, scale * 1.2));
}
// Si el servidor no envió paint_init todavía, arrancamos con valores por defecto
if (!receivedInit) {
initCanvas(1920, 1080);
resizeCanvas();
requestAnimationFrame(() => {
reportViewport();
});
}
// Procesar mensajes pendientes
pendingMessages.forEach(handleMessage);
pendingMessages = [];
})
.catch((err) => {
console.error('[paint/tv] Error cargando HTML:', err);
});
const onResize = () => {
if (canvas) resizeCanvas();
};
window.addEventListener('resize', onResize);
// ============================================================
// CANVAS
// ============================================================
function clearCheckpoints() {
checkpoints = [];
}
function saveCheckpoint(index) {
if (index < 0 || !ctx) return;
try {
const imageData = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
checkpoints.push({ index, imageData });
checkpoints.sort((a, b) => a.index - b.index);
if (checkpoints.length > MAX_CHECKPOINTS) {
checkpoints.shift();
}
} catch (e) {
console.log('[paint/tv] Could not create checkpoint:', e.message);
}
}
function initCanvas(w, h) {
if (!canvas || !ctx) return;
CANVAS_WIDTH = w || 1920;
CANVAS_HEIGHT = h || 1080;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
updateTransform();
resizeCanvas();
}
function updateTransform() {
if (!canvas) return;
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
if (zoomLabel) {
zoomLabel.textContent = Math.round(scale * 100) + '%';
}
}
function resizeCanvas() {
if (!wrapper || !canvas) return;
const rect = wrapper.getBoundingClientRect();
const aspect = CANVAS_WIDTH / CANVAS_HEIGHT;
const w = rect.width;
const h = rect.height;
if (w <= 0 || h <= 0) return;
let displayW, displayH;
if (w / h > aspect) {
displayH = h;
displayW = h * aspect;
} else {
displayW = w;
displayH = w / aspect;
}
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
canvas.style.left = (w - displayW) / 2 + 'px';
canvas.style.top = (h - displayH) / 2 + 'px';
}
function applyCenteredZoom(newScale) {
if (!canvas) return;
const centerX = -offsetX + (CANVAS_WIDTH * scale) / 2;
const centerY = -offsetY + (CANVAS_HEIGHT * scale) / 2;
const scaleRatio = newScale / scale;
offsetX = -(centerX * scaleRatio) + (CANVAS_WIDTH * newScale) / 2;
offsetY = -(centerY * scaleRatio) + (CANVAS_HEIGHT * newScale) / 2;
scale = newScale;
updateTransform();
}
// ============================================================
// DIBUJO
// ============================================================
function drawLine(x1, y1, x2, y2, color, size) {
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color || '#000000';
ctx.lineWidth = size || 5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
function drawPoint(point) {
if (!point || !ctx) return;
if (point.start || point.prevX === undefined || point.prevX === null) {
ctx.beginPath();
ctx.arc(point.x, point.y, (point.size || 5) / 2, 0, Math.PI * 2);
ctx.fillStyle = point.color || '#000000';
ctx.fill();
return;
}
drawLine(point.prevX, point.prevY, point.x, point.y, point.color, point.size);
}
function applyEvent(p) {
if (!p) return;
if (p.type === 'fill') {
floodFillLocal(p.x, p.y, p.color);
} else {
drawPoint(p);
}
}
function renderUpTo(targetIndex) {
if (!ctx) return;
let base = null;
for (let i = checkpoints.length - 1; i >= 0; i--) {
if (checkpoints[i].index <= targetIndex) {
base = checkpoints[i];
break;
}
}
let startIndex;
if (base) {
ctx.putImageData(base.imageData, 0, 0);
startIndex = base.index + 1;
} else {
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
startIndex = 0;
}
let hadFill = false;
for (let i = startIndex; i <= targetIndex; i++) {
const p = globalPoints[i];
if (!p) continue;
applyEvent(p);
if (p.type === 'fill') hadFill = true;
}
currentIndex = targetIndex;
const sinceCheckpoint = targetIndex - (base ? base.index : -1);
if (targetIndex >= 0 && (hadFill || sinceCheckpoint >= CHECKPOINT_INTERVAL)) {
saveCheckpoint(targetIndex);
}
}
// ============================================================
// FILL LOCAL
// ============================================================
function floodFillLocal(x, y, fillColor) {
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const data = imageData.data;
const width = CANVAS_WIDTH;
const height = CANVAS_HEIGHT;
const idx = (y * width + x) * 4;
const targetR = data[idx];
const targetG = data[idx + 1];
const targetB = data[idx + 2];
const fillR = parseInt(fillColor.slice(1, 3), 16);
const fillG = parseInt(fillColor.slice(3, 5), 16);
const fillB = parseInt(fillColor.slice(5, 7), 16);
if (targetR === fillR && targetG === fillG && targetB === fillB) return;
const TOLERANCE = 30;
function colorMatch(r, g, b) {
return (
Math.abs(r - targetR) <= TOLERANCE &&
Math.abs(g - targetG) <= TOLERANCE &&
Math.abs(b - targetB) <= TOLERANCE
);
}
const queue = [{ x, y }];
const visited = new Set();
const key = (x, y) => `${x},${y}`;
while (queue.length > 0) {
const { x: cx, y: cy } = queue.shift();
const k = key(cx, cy);
if (visited.has(k)) continue;
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
const idx2 = (cy * width + cx) * 4;
if (!colorMatch(data[idx2], data[idx2 + 1], data[idx2 + 2])) continue;
visited.add(k);
data[idx2] = fillR;
data[idx2 + 1] = fillG;
data[idx2 + 2] = fillB;
data[idx2 + 3] = 255;
queue.push({ x: cx + 1, y: cy });
queue.push({ x: cx - 1, y: cy });
queue.push({ x: cx, y: cy + 1 });
queue.push({ x: cx, y: cy - 1 });
}
ctx.putImageData(imageData, 0, 0);
}
// ============================================================
// VIEWPORT
// ============================================================
function reportViewport() {
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
requestAnimationFrame(reportViewport);
return;
}
sdk.sendApp({
type: 'viewport',
width: Math.round(rect.width),
height: Math.round(rect.height)
});
}
// ============================================================
// UNMOUNT
// ============================================================
return function unmount() {
destroyed = true;
if (typeof unsubscribe === 'function') {
unsubscribe();
}
window.removeEventListener('resize', onResize);
canvas = null;
ctx = null;
wrapper = null;
zoomLabel = null;
checkpoints = [];
globalPoints = [];
pendingMessages = [];
};
} .\app-runtime-worker.js 14096 caracteres const path = require("path");
let app = null;
let appContext = null;
process.on("message", async (message) => {
try {
await handleMessage(message);
} catch (err) {
reportError(err);
}
});
process.on("uncaughtException", (err) => {
reportError(err);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
reportError(err);
process.exit(1);
});
async function handleMessage(message) {
if (!message || typeof message !== "object") {
return;
}
switch (message.type) {
case "start":
await startApp(message);
break;
case "message":
await callHook("onMessage", message.data, message.player);
break;
case "player_joined":
await callHook("onPlayerJoined", message.player);
break;
case "player_connected":
await callHook("onPlayerConnected", message.player);
break;
case "player_disconnected":
await callHook("onPlayerDisconnected", message.player);
break;
case "player_left":
await callHook("onPlayerLeft", message.player);
break;
case "players_update":
if (appContext) {
appContext.setPlayers(message.players);
}
break;
case "state_sync":
if (appContext) {
appContext.state.replace(message.state, message.revision);
}
break;
case "event":
if (appContext) {
appContext.deliverEvent(message.event);
}
break;
case "event_history_result":
if (appContext) {
appContext.resolveEventHistory(
message.requestId,
message.result,
message.error,
);
}
break;
case "profile_get_result":
if (appContext) {
appContext.resolveProfileRequest(
message.requestId,
message.result,
message.error,
);
}
break;
case "profile_get_app_data_result":
if (appContext) {
appContext.resolveProfileRequest(
message.requestId,
message.result,
message.error,
);
}
break;
case "profile_set_app_data_result":
if (appContext) {
appContext.resolveProfileRequest(
message.requestId,
message.result,
message.error,
);
}
break;
case "stop":
await stopApp();
break;
default:
console.warn(`[AppRuntime Worker] Unknown message: ${message.type}`);
}
}
async function startApp(message) {
const serverPath = path.resolve(message.serverPath);
delete require.cache[require.resolve(serverPath)];
const factory = require(serverPath);
if (typeof factory !== "function") {
throw new Error(
`App server "${serverPath}" must export a createApp(ctx) function`,
);
}
appContext = createContext(message);
app = await factory(appContext);
if (!app || typeof app !== "object") {
throw new Error("createApp(ctx) must return an app object");
}
if (typeof app.onStart === "function") {
await app.onStart();
}
send({
type: "ready",
});
}
async function stopApp() {
try {
if (app && typeof app.onStop === "function") {
await app.onStop();
}
} finally {
if (appContext) {
appContext.clearTimers();
appContext.clearEventSubscriptions();
}
app = null;
appContext = null;
send({
type: "stopped",
});
setImmediate(() => {
process.exit(0);
});
}
}
async function callHook(hook, ...args) {
if (!app || typeof app[hook] !== "function") {
return;
}
await app[hook](...args);
}
function createContext(message) {
let state = clone(message.state || {});
let revision = Number.isInteger(message.stateRevision)
? message.stateRevision
: 0;
let players = clone(Array.isArray(message.players) ? message.players : []);
const timers = new Set();
const eventHandlers = new Map();
const pendingEventRequests = new Map();
const pendingProfileRequests = new Map();
let eventRequestCounter = 0;
let profileRequestCounter = 0;
const context = {
// ====================================================
// APP
// ====================================================
app: {
id: message.app.id,
instanceId: message.app.instanceId,
},
// ====================================================
// PLAYERS
// ====================================================
get players() {
return clone(players);
},
getPlayer(playerId) {
return players.find((player) => player.id === playerId) || null;
},
setPlayers(nextPlayers) {
players = clone(Array.isArray(nextPlayers) ? nextPlayers : []);
},
// ====================================================
// PROFILE
// ====================================================
//
// Convenience API for the current app.
//
// ctx.profile.get(profileId)
// ctx.profile.getAppData(profileId)
// ctx.profile.setAppData(profileId, data)
//
// ====================================================
profile: {
async get(profileId) {
return requestProfile("profile_get", {
profileId,
});
},
async getAppData(profileId) {
return requestProfile("profile_get_app_data", {
profileId,
appId: message.app.id,
});
},
async setAppData(profileId, data) {
return requestProfile("profile_set_app_data", {
profileId,
appId: message.app.id,
data,
});
},
},
// ====================================================
// PROFILES
// ====================================================
//
// Full API.
//
// profileId is always explicit.
//
// ====================================================
profiles: {
async get(profileId) {
return requestProfile("profile_get", {
profileId,
});
},
async getAppData(profileId, appId) {
return requestProfile("profile_get_app_data", {
profileId,
appId,
});
},
async setAppData(profileId, appId, data) {
return requestProfile("profile_set_app_data", {
profileId,
appId,
data,
});
},
},
// ====================================================
// MESSAGING
// ====================================================
broadcast(data) {
send({
type: "broadcast",
data: clone(data),
});
},
sendTo(playerId, data) {
send({
type: "send_to",
playerId,
data: clone(data),
});
},
// ====================================================
// STATE
// ====================================================
state: {
get() {
return clone(state);
},
set(nextState) {
assertSerializable(nextState);
state = clone(nextState);
revision++;
send({
type: "state_set",
state: clone(state),
revision,
});
return clone(state);
},
update(updater) {
if (typeof updater !== "function") {
throw new TypeError("ctx.state.update() requires a function");
}
const nextState = updater(clone(state));
if (nextState === undefined) {
throw new Error(
"ctx.state.update() updater must return the new state",
);
}
return context.state.set(nextState);
},
snapshot() {
return {
revision,
state: clone(state),
};
},
replace(nextState, nextRevision = 0) {
assertSerializable(nextState);
state = clone(nextState);
revision = Number.isInteger(nextRevision) ? nextRevision : 0;
},
},
// ====================================================
// EVENTS
// ====================================================
events: {
emit(type, payload = {}) {
if (typeof type !== "string" || !type.trim()) {
throw new TypeError(
"ctx.events.emit() requires a non-empty event type",
);
}
assertSerializable(payload);
send({
type: "event_emit",
eventType: type,
payload: clone(payload),
});
},
async history(options = {}) {
const requestId = `${Date.now()}-${++eventRequestCounter}`;
return new Promise((resolve, reject) => {
pendingEventRequests.set(requestId, {
resolve,
reject,
});
send({
type: "event_history",
requestId,
options: clone(options),
});
});
},
async replay(options = {}) {
const history = await context.events.history(options);
for (const event of history) {
context.deliverEvent(event);
}
return history;
},
on(type, handler) {
if (typeof type !== "string" || !type.trim()) {
throw new TypeError(
"ctx.events.on() requires a non-empty event type",
);
}
if (typeof handler !== "function") {
throw new TypeError("ctx.events.on() requires a function");
}
if (!eventHandlers.has(type)) {
eventHandlers.set(type, new Set());
}
const handlers = eventHandlers.get(type);
handlers.add(handler);
if (handlers.size === 1) {
send({
type: "event_subscribe",
eventType: type,
});
}
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
eventHandlers.delete(type);
send({
type: "event_unsubscribe",
eventType: type,
});
}
};
},
},
resolveEventHistory(requestId, result, error) {
const pending = pendingEventRequests.get(requestId);
if (!pending) {
return;
}
pendingEventRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
return;
}
pending.resolve(clone(result));
},
resolveProfileRequest(requestId, result, error) {
const pending = pendingProfileRequests.get(requestId);
if (!pending) {
return;
}
pendingProfileRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
return;
}
pending.resolve(clone(result));
},
deliverEvent(event) {
if (!event || typeof event.type !== "string") {
return;
}
const handlers = eventHandlers.get(event.type);
if (!handlers) {
return;
}
for (const handler of [...handlers]) {
try {
handler(clone(event));
} catch (err) {
reportError(err);
}
}
},
// ====================================================
// TIMERS
// ====================================================
timers: {
setTimeout(callback, delay, ...args) {
if (typeof callback !== "function") {
throw new TypeError("ctx.timers.setTimeout() requires a function");
}
const timer = setTimeout(() => {
timers.delete(timer);
callback(...args);
}, delay);
timers.add(timer);
return timer;
},
setInterval(callback, delay, ...args) {
if (typeof callback !== "function") {
throw new TypeError("ctx.timers.setInterval() requires a function");
}
const timer = setInterval(callback, delay, ...args);
timers.add(timer);
return timer;
},
clearTimeout(timer) {
clearTimeout(timer);
timers.delete(timer);
},
clearInterval(timer) {
clearInterval(timer);
timers.delete(timer);
},
},
// ====================================================
// LOGGING / CLEANUP
// ====================================================
log(...args) {
send({
type: "log",
args: clone(args),
});
},
clearTimers() {
for (const timer of timers) {
clearTimeout(timer);
clearInterval(timer);
}
timers.clear();
},
clearEventSubscriptions() {
for (const [type, handlers] of eventHandlers) {
handlers.clear();
send({
type: "event_unsubscribe",
eventType: type,
});
}
eventHandlers.clear();
for (const pending of pendingEventRequests.values()) {
pending.reject(new Error("App runtime stopped"));
}
pendingEventRequests.clear();
for (const pending of pendingProfileRequests.values()) {
pending.reject(new Error("App runtime stopped"));
}
pendingProfileRequests.clear();
},
};
function requestProfile(type, payload) {
const requestId = `${Date.now()}-${++profileRequestCounter}`;
return new Promise((resolve, reject) => {
pendingProfileRequests.set(requestId, {
resolve,
reject,
});
send({
type,
requestId,
...payload,
});
});
}
return context;
}
function assertSerializable(value) {
try {
structuredClone(value);
} catch (err) {
throw new TypeError(`Value must be structured-cloneable: ${err.message}`);
}
}
function clone(value) {
if (value === undefined) {
return undefined;
}
return structuredClone(value);
}
function reportError(err) {
send({
type: "app_error",
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
function send(message) {
if (process.send) {
process.send(message);
}
}
.\apps\dice\client\tv.js 14267 caracteres // TV side. 3D dice using local Dice-Box.
//
// IMPORTANTE (compatibilidad con el core nuevo):
// - mount() NO es async. Carga el HTML con .then() y se suscribe a
// sdk.onApp() INMEDIATAMENTE, antes de que el core pueda recibir
// el broadcast inicial del servidor.
// - Devuelve la función de unmount de forma síncrona.
export function mount(container, sdk) {
// ============================================================
// ESTADO LOCAL
// ============================================================
let destroyed = false;
const diceObjects = new Map();
let diceCount = 0;
let totalSum = 0;
let diceResults = [];
let currentPlayerColor = '#FF0000';
let currentConfiguration = {
scale: 6,
gravity: 3,
friction: 0.4,
restitution: 1,
mass: 4,
themeColor: '#FF0000',
launchTime: 0
};
let Box = null;
let pendingMessages = [];
let htmlReady = false;
// ============================================================
// SUSCRIPCIÓN INMEDIATA AL SDK
//
// Se registra ANTES de cargar el HTML para no perder el
// initial_state / configuration_updated que el servidor
// pueda emitir nada más arrancar.
// ============================================================
const unsubscribe = sdk.onApp((data) => {
if (!data) return;
if (!htmlReady) {
pendingMessages.push(data);
return;
}
handleMessage(data);
});
function handleMessage(data) {
if (!Box) return;
switch (data.type) {
case 'initial_state':
lastAction.textContent = data.message || 'Table ready';
break;
case 'configuration_updated':
if (data.configuration) {
const cfg = data.configuration;
if (cfg.scale !== undefined) currentConfiguration.scale = cfg.scale;
if (cfg.gravity !== undefined) currentConfiguration.gravity = cfg.gravity;
if (cfg.friction !== undefined) currentConfiguration.friction = cfg.friction;
if (cfg.restitution !== undefined) currentConfiguration.restitution = cfg.restitution;
if (cfg.mass !== undefined) currentConfiguration.mass = cfg.mass;
if (cfg.launchTime !== undefined) currentConfiguration.launchTime = cfg.launchTime;
if (cfg.color !== undefined) {
currentConfiguration.themeColor = cfg.color;
currentPlayerColor = cfg.color;
}
if (Box.updateConfig) {
const updates = {};
if (cfg.scale !== undefined) updates.scale = cfg.scale;
if (cfg.gravity !== undefined) updates.gravity = cfg.gravity;
if (cfg.friction !== undefined) updates.friction = cfg.friction;
if (cfg.restitution !== undefined) updates.restitution = cfg.restitution;
if (cfg.mass !== undefined) updates.mass = cfg.mass;
if (cfg.launchTime !== undefined) updates.delay = cfg.launchTime;
if (cfg.color !== undefined) updates.themeColor = cfg.color;
if (Object.keys(updates).length > 0) {
Box.updateConfig(updates);
}
}
}
break;
case 'dice_rolled':
if (data.dice && data.dice.length > 0 && data.dice[0].color) {
currentPlayerColor = data.dice[0].color;
}
rollNewDice(data.dice);
break;
case 'dice_returned':
clearFullTable();
lastAction.textContent = `${data.playerId} picked up their dice`;
break;
case 'remove_table_dice':
removeSpecificDice(data.diceIds || []);
break;
case 'clear_full_table':
clearFullTable();
lastAction.textContent = 'Clean table';
break;
case 'reroll_dice':
rerollDice(data.diceTypes || ['d6'], data.configuration || null);
break;
}
}
// ============================================================
// CARGA DEL HTML Y CSS
// ============================================================
const cssId = 'dice-tv-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/dice/client/tv/tv.css`;
document.head.appendChild(link);
}
fetch(`/apps/dice/client/tv/tv.html`)
.then((response) => response.text())
.then((html) => {
if (destroyed) return;
container.innerHTML = html;
// Referencias del DOM
lastAction = container.querySelector('#lastAction');
tableStats = container.querySelector('#tableStats');
totalSumEl = container.querySelector('#total-sum');
htmlReady = true;
// Procesar los mensajes que llegaron antes de tener HTML
pendingMessages.forEach(handleMessage);
pendingMessages = [];
// Arrancar el motor 3D
initDiceBox();
})
.catch((err) => {
console.error('[dice/tv] Error cargando HTML:', err);
if (lastAction) lastAction.textContent = 'Error: ' + err.message;
});
// Referencias (se rellenan cuando el HTML esté listo)
let lastAction = null;
let tableStats = null;
let totalSumEl = null;
// ============================================================
// DICE-BOX
// ============================================================
async function initDiceBox() {
console.log('[dice/tv] 1. Starting load...');
try {
console.log('[dice/tv] 2. Importing DiceBox...');
const DiceBoxModule = await import('/apps/dice/assets/dice-box/dice-box.es.js');
const DiceBoxClass = DiceBoxModule.default;
console.log('[dice/tv] 3. Creating Box...');
Box = new DiceBoxClass('#dice-box', {
assetPath: '/apps/dice/assets/dice-box/assets/',
theme: 'default',
offscreen: false,
scale: currentConfiguration.scale,
gravity: currentConfiguration.gravity,
friction: currentConfiguration.friction,
restitution: currentConfiguration.restitution,
mass: currentConfiguration.mass,
themeColor: currentConfiguration.themeColor,
delay: currentConfiguration.launchTime,
settlingTime: 8000
});
console.log('[dice/tv] 4. Initializing Box...');
await Box.init();
console.log('[dice/tv] 5. Box initialized');
const canvas = document.querySelector('#dice-box canvas');
if (canvas) {
const containerElement = document.querySelector('#dice-box');
if (canvas.width === 0 || canvas.height === 0) {
canvas.width = containerElement.clientWidth;
canvas.height = containerElement.clientHeight;
canvas.style.width = '100%';
canvas.style.height = '100%';
}
}
assignOnRollComplete();
lastAction.textContent = 'Table ready';
// Procesar mensajes pendientes por si llegaron mientras init()
pendingMessages.forEach(handleMessage);
pendingMessages = [];
} catch (error) {
console.error('[dice/tv] FULL ERROR:', error);
if (lastAction) lastAction.textContent = 'Error: ' + error.message;
}
}
function assignOnRollComplete() {
Box.onRollComplete = (results) => {
console.log('[dice/tv] onRollComplete');
if (!results || results.length === 0) return;
const totalSumResult = results.reduce((acc, result) => {
return acc + (result.value || 0);
}, 0);
totalSum = totalSumResult;
if (totalSumEl) totalSumEl.textContent = totalSumResult;
if (lastAction) lastAction.textContent = `Total sum: ${totalSumResult}`;
diceObjects.clear();
diceResults = results.map((result, index) => {
const resultId = `result_${Date.now()}_${index}`;
let rollId = null;
let groupId = null;
if (result.rolls && result.rolls.length > 0) {
rollId = result.rolls[0].rollId;
groupId = result.rolls[0].groupId;
} else {
rollId = result.rollId;
groupId = result.groupId;
}
diceObjects.set(resultId, {
rollId: rollId,
groupId: groupId,
diceId: result.id,
diceType: result.sides || 'd6',
playerId: 'everyone'
});
return {
id: resultId,
diceType: result.sides || 'd6',
value: result.value || 0,
playerId: 'everyone'
};
});
sdk.sendApp({
type: 'table_results',
results: diceResults,
totalSum: totalSumResult
});
};
}
function rollNewDice(dice) {
if (!Box || !dice) return;
dice.forEach((die) => {
const type = die.diceType.replace('d', '');
const notation = `1d${type}`;
if (die.color && Box.updateConfig) {
Box.updateConfig({ themeColor: die.color });
}
Box.add(notation);
});
diceCount += dice.length;
if (tableStats) tableStats.textContent = `${diceCount} dice`;
if (lastAction) {
lastAction.textContent = `${dice[0].playerId} rolled ${dice.length} die(s)`;
}
}
async function restartBox() {
console.log('[dice/tv] Restarting Box');
if (Box && Box.destroy) {
Box.destroy();
}
const diceBoxContainer = document.querySelector('#dice-box');
if (diceBoxContainer) {
diceBoxContainer.innerHTML = '<div id="total-sum">0</div>';
totalSumEl = diceBoxContainer.querySelector('#total-sum');
}
diceObjects.clear();
diceResults = [];
diceCount = 0;
totalSum = 0;
if (totalSumEl) totalSumEl.textContent = '0';
if (tableStats) tableStats.textContent = '0 dice';
try {
const DiceBoxModule = await import('/apps/dice/assets/dice-box/dice-box.es.js');
const DiceBoxClass = DiceBoxModule.default;
Box = new DiceBoxClass('#dice-box', {
assetPath: '/apps/dice/assets/dice-box/assets/',
theme: 'default',
offscreen: false,
scale: currentConfiguration.scale,
gravity: currentConfiguration.gravity,
friction: currentConfiguration.friction,
restitution: currentConfiguration.restitution,
mass: currentConfiguration.mass,
themeColor: currentConfiguration.themeColor,
delay: currentConfiguration.launchTime
});
await Box.init();
const newCanvas = document.querySelector('#dice-box canvas');
if (newCanvas) {
const containerElement = document.querySelector('#dice-box');
newCanvas.width = containerElement.clientWidth;
newCanvas.height = containerElement.clientHeight;
newCanvas.style.width = '100%';
newCanvas.style.height = '100%';
}
assignOnRollComplete();
} catch (error) {
console.error('[dice/tv] Error reiniciando Box:', error);
}
}
function clearFullTable() {
console.log('[dice/tv] Clearing full table');
restartBox();
}
async function rerollDice(diceTypes, configuration) {
console.log('[dice/tv] Rerolling');
await restartBox();
if (configuration && configuration.color && Box && Box.updateConfig) {
Box.updateConfig({ themeColor: configuration.color });
}
diceTypes.forEach((type) => {
Box.add(`1d${type}`);
});
diceCount = diceTypes.length;
if (tableStats) tableStats.textContent = `${diceCount} dice`;
if (lastAction) lastAction.textContent = `Rerolled ${diceTypes.length} die(s)`;
}
async function removeSpecificDice(diceIds) {
console.log('[dice/tv] Removing dice:', diceIds);
const diceToKeep = [];
diceResults.forEach((result) => {
if (!diceIds.includes(result.id)) {
diceToKeep.push(result);
}
});
await restartBox();
diceToKeep.forEach((die) => {
const type = die.diceType.replace('d', '');
Box.add(`1d${type}`);
});
diceCount = diceToKeep.length;
if (tableStats) tableStats.textContent = `${diceCount} dice`;
if (lastAction) lastAction.textContent = `Removed ${diceIds.length} die(s)`;
}
// ============================================================
// UNMOUNT
// ============================================================
return function unmount() {
destroyed = true;
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (Box && Box.destroy) {
try { Box.destroy(); } catch (_) {}
}
Box = null;
pendingMessages = [];
};
} .\apps\core-showcase\server.js 14748 caracteres const TEST_NAMES = [
'Players',
'Profile',
'App Save',
'State',
'Events',
'Timers',
'Messaging',
'Lifecycle'
];
module.exports = function createApp(ctx) {
let results = {};
let lifecycleEvents = [];
let timerTicks = 0;
let timerHandle = null;
function resetResults() {
results = {};
for (const name of TEST_NAMES) {
results[name] = {
status: 'waiting',
message: ''
};
}
}
function publish() {
const passed =
Object.values(results)
.filter(
result =>
result.status === 'pass'
).length;
const failed =
Object.values(results)
.filter(
result =>
result.status === 'fail'
).length;
const total =
TEST_NAMES.length;
ctx.broadcast({
type: 'showcase_update',
results,
summary: {
passed,
failed,
total
}
});
}
function pass(name, message) {
results[name] = {
status: 'pass',
message
};
publish();
}
function fail(name, message) {
results[name] = {
status: 'fail',
message
};
publish();
}
function waiting(name, message = '') {
results[name] = {
status: 'waiting',
message
};
publish();
}
function resetRuntimeTestData() {
lifecycleEvents = [];
timerTicks = 0;
if (timerHandle) {
clearInterval(timerHandle);
timerHandle = null;
}
}
async function testPlayers() {
try {
if (
!Array.isArray(ctx.players)
) {
throw new Error(
'ctx.players is not an array'
);
}
for (
const player
of ctx.players
) {
if (
!player.id ||
!player.connectionId ||
!player.profileId
) {
throw new Error(
'Player is missing id, connectionId or profileId'
);
}
if (
typeof player.name !==
'string'
) {
throw new Error(
'Player name is invalid'
);
}
}
pass(
'Players',
`Players API OK (${ctx.players.length})`
);
} catch (err) {
fail(
'Players',
err.message
);
}
}
async function testProfile(player) {
try {
if (!player) {
throw new Error(
'No player supplied'
);
}
if (!player.profileId) {
throw new Error(
'Player has no profileId'
);
}
const profile =
await ctx.profiles.get(
player.profileId
);
if (!profile) {
throw new Error(
'Profile was not found'
);
}
if (
profile.id !==
player.profileId
) {
throw new Error(
'Profile ID mismatch'
);
}
if (
typeof profile.name !==
'string'
) {
throw new Error(
'Profile name is invalid'
);
}
pass(
'Profile',
`Profile API OK (${profile.id})`
);
} catch (err) {
fail(
'Profile',
err.message
);
}
}
async function testAppSave(player) {
try {
if (!player) {
throw new Error(
'No player supplied'
);
}
const value = {
test: 'core-showcase',
number:
Math.floor(
Math.random() *
1000000
),
timestamp:
new Date().toISOString()
};
await ctx.profiles.setAppData(
player.profileId,
ctx.app.id,
value
);
const loaded =
await ctx.profiles.getAppData(
player.profileId,
ctx.app.id
);
if (
JSON.stringify(loaded) !==
JSON.stringify(value)
) {
throw new Error(
'Saved data does not match loaded data'
);
}
pass(
'App Save',
'Persistent app profile data OK'
);
} catch (err) {
fail(
'App Save',
err.message
);
}
}
async function testState() {
try {
const before =
ctx.state.get();
const nextValue =
Math.floor(
Math.random() *
1000000
);
ctx.state.set({
...before,
showcaseValue:
nextValue
});
const after =
ctx.state.get();
if (
after.showcaseValue !==
nextValue
) {
throw new Error(
'State value was not stored'
);
}
const snapshot =
ctx.state.snapshot();
if (
typeof snapshot.revision !==
'number'
) {
throw new Error(
'State revision is invalid'
);
}
pass(
'State',
`State API OK (revision ${snapshot.revision})`
);
} catch (err) {
fail(
'State',
err.message
);
}
}
async function testEvents() {
try {
const value =
Math.floor(
Math.random() *
1000000
);
ctx.events.emit(
'SHOWCASE_TEST',
{
value
}
);
await wait(50);
const history =
await ctx.events.history({
type:
'SHOWCASE_TEST'
});
const matching =
history.find(
event =>
event.payload &&
event.payload.value ===
value
);
if (!matching) {
throw new Error(
'Event was not found in history'
);
}
pass(
'Events',
'Event emission and history OK'
);
} catch (err) {
fail(
'Events',
err.message
);
}
}
async function testTimers() {
try {
timerTicks = 0;
if (timerHandle) {
clearInterval(timerHandle);
timerHandle = null;
}
await new Promise(
resolve => {
timerHandle =
setInterval(
() => {
timerTicks++;
if (
timerTicks >=
3
) {
clearInterval(
timerHandle
);
timerHandle =
null;
resolve();
}
},
25
);
}
);
if (timerTicks < 3) {
throw new Error(
'Timer did not tick three times'
);
}
pass(
'Timers',
`Timers OK (${timerTicks} ticks)`
);
} catch (err) {
fail(
'Timers',
err.message
);
}
}
async function testMessaging() {
try {
ctx.broadcast({
type:
'showcase_ping',
timestamp:
Date.now()
});
pass(
'Messaging',
'Core broadcast API OK'
);
} catch (err) {
fail(
'Messaging',
err.message
);
}
}
async function testLifecycle() {
try {
if (
!Array.isArray(
lifecycleEvents
)
) {
throw new Error(
'Lifecycle tracking is invalid'
);
}
pass(
'Lifecycle',
lifecycleEvents.length
? lifecycleEvents.join(
' → '
)
: 'Lifecycle hooks available'
);
} catch (err) {
fail(
'Lifecycle',
err.message
);
}
}
async function runAll(player) {
resetResults();
resetRuntimeTestData();
publish();
await testPlayers();
await testProfile(player);
await testAppSave(player);
await testState();
await testEvents();
await testTimers();
await testMessaging();
await testLifecycle();
publish();
}
return {
async onStart() {
resetResults();
resetRuntimeTestData();
ctx.log(
'Core Showcase started'
);
publish();
},
async onPlayerJoined(player) {
lifecycleEvents.push(
'joined'
);
ctx.broadcast({
type:
'showcase_lifecycle',
message:
`Player joined: ${player.name}`
});
publish();
},
async onPlayerConnected(player) {
lifecycleEvents.push(
'connected'
);
ctx.broadcast({
type:
'showcase_lifecycle',
message:
`Player connected: ${player.name}`
});
publish();
},
async onPlayerDisconnected(player) {
lifecycleEvents.push(
'disconnected'
);
ctx.broadcast({
type:
'showcase_lifecycle',
message:
`Player disconnected: ${player.name}`
});
publish();
},
async onPlayerLeft(player) {
lifecycleEvents.push(
'left'
);
ctx.broadcast({
type:
'showcase_lifecycle',
message:
`Player left: ${player.name}`
});
publish();
},
async onMessage(data, player) {
if (!data) {
return;
}
if (
data.type ===
'run_all'
) {
await runAll(player);
return;
}
if (
data.type ===
'test_players'
) {
await testPlayers();
return;
}
if (
data.type ===
'test_profile'
) {
await testProfile(player);
return;
}
if (
data.type ===
'test_save'
) {
await testAppSave(player);
return;
}
if (
data.type ===
'test_state'
) {
await testState();
return;
}
if (
data.type ===
'test_events'
) {
await testEvents();
return;
}
if (
data.type ===
'test_timers'
) {
await testTimers();
return;
}
if (
data.type ===
'test_messaging'
) {
await testMessaging();
return;
}
if (
data.type ===
'test_lifecycle'
) {
await testLifecycle();
return;
}
if (
data.type ===
'crash'
) {
ctx.log(
'Core Showcase intentional crash test'
);
throw new Error(
'CORE SHOWCASE intentional crash test'
);
}
},
async onStop() {
if (timerHandle) {
clearInterval(
timerHandle
);
timerHandle = null;
}
ctx.log(
'Core Showcase stopped'
);
}
};
};
function wait(ms) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
} .\public\sdk.js 14891 caracteres // Shared SDK by the platform
import {
startGamepadListener,
stopGamepadListener,
getSnapshot as getGamepadSnapshot,
loadRemap,
saveRemap,
clearRemap,
} from "./gamepad.js";
import {
startSensorListener,
stopSensorListener,
getSensorSnapshot as getSensorSnapshotInternal,
getSensorPermissionState,
requestSensorPermissions,
} from "./sensors.js";
export function connectPlatform(role, name, sessionId, options = {}) {
const forceAdmin = !!options.forceAdmin;
const suplantarConnectionId = options.suplantarConnectionId || null;
let ws;
const listeners = {};
let appMessageBuffer = [];
let reconnectAttempts = 0;
let reconnectTimer = null;
let savedSessionId = sessionId;
let savedName = name;
const MAX_RECONNECT_ATTEMPTS = 20;
const RECONNECT_INTERVAL = 1500;
// ----- GAMEPAD -----
let gamepadEnabled = false;
let gamepadSnapshotTimer = null;
const GAMEPAD_SNAPSHOT_INTERVAL_MS = 80;
const GAMEPAD_KEEPALIVE_MS = 2000;
let lastGamepadSignature = "";
let lastGamepadSentAt = 0;
// ----- SENSORS -----
let sensorsEnabled = false;
let sensorSnapshotTimer = null;
const SENSOR_SNAPSHOT_INTERVAL_MS = 50;
const SENSOR_KEEPALIVE_MS = 2000;
let lastSensorSignature = "";
let lastSensorSentAt = 0;
const state = {
connected: false,
playerId: null,
profileId: null,
isAdmin: false,
adminId: null,
adminSuccessorId: null,
mainScreenId: null,
players: [],
apps: [],
appLayout: { version: 1, root: [] },
appWants: {},
lockedPlayers: new Set(),
localState: {},
suplantando: null,
currentAppId: null,
sessionId: null,
apiVersion: null,
reconnecting: false,
settings: { maxPlayerNameLength: 20 },
sensors: { motion: false, orientation: false },
};
function on(type, cb) {
(listeners[type] = listeners[type] || []).push(cb);
if (type === "app" && appMessageBuffer.length) {
const buffered = appMessageBuffer;
appMessageBuffer = [];
buffered.forEach((d) => cb(d));
}
return () => {
listeners[type] = listeners[type].filter((f) => f !== cb);
};
}
function off(type, cb) {
if (!listeners[type]) return;
listeners[type] = listeners[type].filter((f) => f !== cb);
}
function emit(type, data) {
(listeners[type] || []).forEach((cb) => cb(data));
}
function send(type, payload = {}) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, ...payload }));
}
}
function sendApp(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "app_message", data: payload }));
}
}
function sendGamepad(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "gamepad_message", data: payload }));
}
}
function sendSensor(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "sensor_message", data: payload }));
}
}
function sendLocalState(localState) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "local_state", state: localState }));
}
}
// ----------------------------------------------------------
// GAMEPAD
// ----------------------------------------------------------
function computeGamepadSignature(snapshot) {
if (snapshot.length === 0) return "empty";
return JSON.stringify(snapshot);
}
function sendGamepadSnapshot(force = false) {
if (!gamepadEnabled) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const snapshot = getGamepadSnapshot();
const signature = computeGamepadSignature(snapshot);
const now = performance.now();
const changed = signature !== lastGamepadSignature;
const keepaliveDue = now - lastGamepadSentAt >= GAMEPAD_KEEPALIVE_MS;
if (!force && !changed && !keepaliveDue) return;
lastGamepadSignature = signature;
lastGamepadSentAt = now;
ws.send(
JSON.stringify({
type: "gamepad_message",
data: {
type: "gamepad_snapshot",
gamepads: snapshot,
},
}),
);
}
function enableGamepad() {
if (gamepadEnabled) return;
gamepadEnabled = true;
startGamepadListener((evt) => {
if (evt.control === "connected" || evt.control === "disconnected") {
sendGamepadSnapshot(true);
}
emit("gamepad", evt);
});
if (!gamepadSnapshotTimer) {
gamepadSnapshotTimer = setInterval(() => {
sendGamepadSnapshot(false);
}, GAMEPAD_SNAPSHOT_INTERVAL_MS);
}
sendGamepadSnapshot(true);
}
function disableGamepad() {
if (!gamepadEnabled) return;
gamepadEnabled = false;
lastGamepadSignature = "";
lastGamepadSentAt = 0;
if (gamepadSnapshotTimer) {
clearInterval(gamepadSnapshotTimer);
gamepadSnapshotTimer = null;
}
stopGamepadListener();
}
// ----------------------------------------------------------
// SENSORS
// ----------------------------------------------------------
function sendSensorSnapshot(force = false) {
if (!sensorsEnabled) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const snapshot = getSensorSnapshotInternal();
const signature = JSON.stringify(snapshot);
const now = performance.now();
const changed = signature !== lastSensorSignature;
const keepaliveDue = now - lastSensorSentAt >= SENSOR_KEEPALIVE_MS;
if (!force && !changed && !keepaliveDue) return;
lastSensorSignature = signature;
lastSensorSentAt = now;
ws.send(
JSON.stringify({
type: "sensor_message",
data: {
type: "sensor_snapshot",
sensors: snapshot,
},
}),
);
}
function enableSensors() {
if (sensorsEnabled) return true;
sensorsEnabled = true;
state.sensors = { motion: true, orientation: true };
startSensorListener((evt) => {
sendSensorSnapshot(true);
emit("sensor", evt);
});
if (!sensorSnapshotTimer) {
sensorSnapshotTimer = setInterval(() => {
sendSensorSnapshot(false);
}, SENSOR_SNAPSHOT_INTERVAL_MS);
}
sendSensorSnapshot(true);
return true;
}
function disableSensors() {
if (!sensorsEnabled) return;
sensorsEnabled = false;
lastSensorSignature = "";
lastSensorSentAt = 0;
if (sensorSnapshotTimer) {
clearInterval(sensorSnapshotTimer);
sensorSnapshotTimer = null;
}
stopSensorListener();
}
// ----------------------------------------------------------
// WEBSOCKET
// ----------------------------------------------------------
function connectSocket() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = location.host;
ws = new WebSocket(`${wsProtocol}//${wsHost}/ws`);
ws.onopen = () => {
state.connected = true;
state.reconnecting = false;
reconnectAttempts = 0;
emit("connected", {});
const identifyPayload = {
role,
name: savedName,
sessionId: savedSessionId || state.sessionId,
forceAdmin,
};
if (suplantarConnectionId) {
identifyPayload.suplantarConnectionId = suplantarConnectionId;
}
send("identify", identifyPayload);
if (gamepadEnabled) {
lastGamepadSignature = "";
lastGamepadSentAt = 0;
sendGamepadSnapshot(true);
}
if (sensorsEnabled) {
lastSensorSignature = "";
lastSensorSentAt = 0;
sendSensorSnapshot(true);
}
};
ws.onclose = () => {
state.connected = false;
emit("disconnected", {});
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
state.reconnecting = true;
reconnectAttempts++;
emit("reconnecting", {
attempt: reconnectAttempts,
maxAttempts: MAX_RECONNECT_ATTEMPTS,
});
reconnectTimer = setTimeout(connectSocket, RECONNECT_INTERVAL);
} else {
state.reconnecting = false;
reconnectAttempts = 0;
emit("reconnect_failed", {});
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "init") {
state.playerId = data.playerId;
state.profileId = data.profileId;
state.isAdmin = data.isAdmin;
state.adminId = data.adminId;
state.adminSuccessorId = data.adminSuccessorId || null;
state.mainScreenId = data.mainScreenId;
state.players = data.players;
state.apps = data.apps;
state.appLayout = data.appLayout || { version: 1, root: [] };
state.appWants = data.appWants || {};
state.lockedPlayers = new Set(data.lockedPlayers || []);
state.localState = data.localState || {};
state.suplantando = data.suplantando || null;
if (data.settings) state.settings = data.settings;
emit("players_changed", { players: state.players });
emit("screens_changed", { mainScreenId: state.mainScreenId });
emit("apps_wants_changed", state.appWants);
emit("locked_changed", state.lockedPlayers);
state.currentAppId = data.currentAppId;
state.apiVersion = data.apiVersion || 1;
if (data.sessionId) {
state.sessionId = data.sessionId;
savedSessionId = data.sessionId;
}
if (reconnectAttempts > 0) {
emit("reconnected", {
playerId: state.playerId,
isAdmin: state.isAdmin,
});
reconnectAttempts = 0;
}
}
if (data.type === "players_update") {
state.players = data.players;
const me = (state.players || []).find((p) => p.id === state.playerId);
state.isAdmin = !!me && me.profileId === state.adminId;
if (state.suplantando) {
const suplantado = (state.players || []).find(
(p) => p.profileId === state.suplantando.profileId,
);
if (suplantado) {
state.suplantando.name = suplantado.name;
}
}
emit("players_changed", { players: state.players });
}
if (data.type === "player_left") {
state.players = data.players;
const me = (state.players || []).find((p) => p.id === state.playerId);
state.isAdmin = !!me && me.profileId === state.adminId;
emit("players_changed", { players: state.players });
}
if (data.type === "admin_changed") {
state.adminId = data.adminId;
const me = (state.players || []).find((p) => p.id === state.playerId);
state.isAdmin = !!me && me.profileId === state.adminId;
state.players = (state.players || []).map((p) => ({
...p,
isAdmin: p.profileId === state.adminId,
}));
emit("players_changed", { players: state.players });
}
if (data.type === "main_screen_changed") {
state.mainScreenId = data.mainScreenId;
emit("screens_changed", { mainScreenId: state.mainScreenId });
}
if (data.type === "apps_list") state.apps = data.apps;
if (data.type === "app_layout") {
state.appLayout = data.layout || { version: 1, root: [] };
}
if (data.type === "settings_changed") {
state.settings = data.settings;
emit("settings_changed", data.settings);
}
if (data.type === "apps_wants_changed") {
state.appWants = data.wants || {};
emit("apps_wants_changed", state.appWants);
}
if (data.type === "player_locked") {
state.lockedPlayers.add(data.profileId);
emit("locked_changed", state.lockedPlayers);
}
if (data.type === "player_unlocked") {
state.lockedPlayers.delete(data.profileId);
emit("locked_changed", state.lockedPlayers);
}
if (data.type === "local_state") {
state.localState = data.state || {};
emit("local_state", state.localState);
}
if (data.type === "app_selected") {
state.currentAppId = data.app ? data.app.id : null;
appMessageBuffer = [];
listeners["app"] = [];
}
emit(data.type, data);
if (data.type === "app_message") {
if (data.appId !== state.currentAppId) return;
if (listeners["app"] && listeners["app"].length > 0) {
emit("app", data.data);
} else {
appMessageBuffer.push(data.data);
if (appMessageBuffer.length > 100) appMessageBuffer.shift();
}
}
if (data.type === "admin_successor_changed") {
state.adminSuccessorId = data.adminSuccessorId || null;
state.players = (state.players || []).map((p) => ({
...p,
isAdminSuccessor: p.profileId === state.adminSuccessorId,
}));
emit("players_changed", { players: state.players });
}
};
}
connectSocket();
return {
state,
on,
off,
send,
sendApp,
sendGamepad,
sendSensor,
sendLocalState,
enableGamepad,
disableGamepad,
enableSensors,
disableSensors,
getSnapshot: getGamepadSnapshot,
getSensorSnapshot: getSensorSnapshotInternal,
getSensorPermissionState,
requestSensorPermissions,
loadRemap,
saveRemap,
clearRemap,
onApp: (cb) => on("app", cb),
clearAppListeners: () => {
listeners["app"] = [];
},
reconnect: () => {
reconnectAttempts = 0;
connectSocket();
},
};
}
export async function mountApp(container, app, side, sdk) {
container.innerHTML = "";
if (!app) return null;
if (app.external) {
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none;";
iframe.src = side === "tv" ? app.tvUrl : app.mobileUrl;
container.appendChild(iframe);
return null;
}
const entry = app.client && app.client[side];
if (!entry) return null;
try {
const mod = await import(`/apps/${app.id}/client/${entry}`);
let unmountFn = null;
if (typeof mod.mount === "function") {
const result = await mod.mount(container, sdk);
if (typeof result === "function") unmountFn = result;
}
if (!unmountFn && typeof mod.unmount === "function") {
unmountFn = mod.unmount;
}
return unmountFn;
} catch (err) {
console.error(`[Platform] Error mounting app "${app.id}":`, err);
container.innerHTML = `
<div style="padding: 20px; color: #ff5555; text-align: center; font-family: sans-serif;">
<h3>Error loading minigame</h3>
<p style="font-size: 0.8em; opacity: 0.8;">${err.message}</p>
</div>
`;
return null;
}
} .\apps\maps\client\mobile\mobile.css 15883 caracteres /* ============================================================
MAPS MOBILE - MAIN LAYOUT
============================================================ */
/* Main container - fills the entire phone screen */
.mobile {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
/* Top toolbar */
.mobile .toolbar {
padding: 4px 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
touch-action: none;
min-height: 34px;
}
/* Content area */
.mobile .content {
flex: 1;
overflow: hidden;
position: relative;
touch-action: none;
}
/* Main app container */
.rutas-app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
overflow: hidden;
position: relative;
}
/* ============================================================
SEARCH SECTION
============================================================ */
.search-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 10px;
flex-shrink: 0;
position: relative;
z-index: 50;
}
.search-header {
margin-bottom: 6px;
}
.search-header h3 {
font-size: 14px;
margin: 0;
}
.search-input-row {
display: flex;
gap: 6px;
}
.search-input-wrapper {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 8px 30px 8px 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: white;
font-size: 13px;
}
.btn-clear-search {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 14px;
padding: 4px;
touch-action: none;
display: none;
}
/* Search results dropdown */
.search-results {
position: absolute;
top: 100%;
left: 10px;
right: 10px;
max-height: 200px;
overflow-y: auto;
background: rgba(20, 24, 40, 0.98);
border-radius: 0 0 8px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: none;
z-index: 100;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.search-result-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.search-result-item:active {
cursor: grabbing;
}
.search-result-item.dragging {
opacity: 0.5;
background: rgba(167, 139, 250, 0.1);
}
.search-result-name {
font-size: 12px;
color: #e0e0e0;
flex: 1;
pointer-events: none;
}
/* ============================================================
ROUTE CONTENT
============================================================ */
.ruta-content {
flex: 1;
overflow: hidden;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.ruta-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 10px;
border: 2px solid transparent;
transition: border-color 0.2s;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ruta-item.drag-over {
border-color: #a78bfa;
background: rgba(167, 139, 250, 0.1);
}
.ruta-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
flex-shrink: 0;
}
.ruta-nombre-input {
font-weight: 600;
font-size: 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: white;
padding: 4px 8px;
width: 150px;
flex: 1;
}
.ruta-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* Action buttons */
.btn-accion {
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
touch-action: auto;
pointer-events: auto;
}
.btn-guardar-ruta {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.btn-visible-toggle {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.btn-eliminar-ruta {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.btn-zoom-ruta {
background: rgba(45, 135, 240, 0.2);
color: #2d87f0;
}
.btn-toggle-marcadores {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.btn-toggle-marcadores.hidden {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
}
.btn-toggle-lineas {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.btn-toggle-lineas.hidden {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
}
.btn-tipo-defecto {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
}
.btn-tipo-defecto.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.btn-cargar-ruta {
background: rgba(45, 135, 240, 0.2);
color: #2d87f0;
}
/* Controls row */
.controls-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
flex-shrink: 0;
}
.controls-row .color-picker {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.controls-row .color-picker span {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.controls-row .color-picker input[type="color"] {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
cursor: pointer;
background: none;
padding: 0;
}
/* Points list */
.puntos-list {
margin-top: 6px;
overflow-y: auto;
flex: 1;
min-height: 0;
touch-action: auto;
overscroll-behavior: contain;
}
.punto-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
margin-bottom: 2px;
touch-action: auto;
user-select: none;
-webkit-user-select: none;
flex-shrink: 0;
position: relative;
}
.punto-item.dragging {
opacity: 0.5;
background: rgba(167, 139, 250, 0.2);
z-index: 10;
}
.punto-item.drag-over {
border-top: 2px solid #a78bfa;
}
.punto-drag-handle {
cursor: grab;
touch-action: none;
font-size: 14px;
padding: 0 4px;
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
pointer-events: auto;
}
.punto-drag-handle:active {
cursor: grabbing;
}
.punto-coords {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
flex: 1;
pointer-events: none;
}
.btn-segmento {
padding: 2px 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
touch-action: auto;
flex-shrink: 0;
pointer-events: auto;
}
.btn-segmento.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
border-color: rgba(76, 175, 80, 0.3);
}
.btn-eliminar-punto {
padding: 2px 6px;
background: rgba(244, 67, 54, 0.2);
color: #f44336;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
touch-action: auto;
flex-shrink: 0;
pointer-events: auto;
}
/* ============================================================
BOTTOM TABS
============================================================ */
.tabs-bottom {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
position: relative;
z-index: 50;
}
.tabs-container {
position: relative;
overflow: hidden;
touch-action: none;
flex: 1;
}
.tabs-track {
display: flex;
transition: transform 0.3s ease-out;
touch-action: none;
}
.tab-item {
flex-shrink: 0;
padding: 10px 16px;
margin-right: 6px;
border-radius: 8px;
cursor: pointer;
touch-action: auto;
user-select: none;
-webkit-user-select: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
transition: all 0.3s;
text-align: center;
min-width: 80px;
white-space: nowrap;
}
.tab-item.active {
background: rgba(167, 139, 250, 0.25);
border-color: #a78bfa;
box-shadow: 0 0 15px rgba(167, 139, 250, 0.3);
color: #a78bfa;
font-weight: 600;
}
.tab-item.inactive {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.3);
}
.btn-new-tab {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(167, 139, 250, 0.2);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.3);
font-size: 20px;
cursor: pointer;
touch-action: auto;
display: flex;
align-items: center;
justify-content: center;
}
/* ============================================================
SETTINGS OVERLAY
============================================================ */
.settings-btn {
padding: 4px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
touch-action: auto;
}
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
touch-action: none;
display: none;
}
.settings-overlay.visible {
display: flex;
}
.settings-panel {
background: rgba(20, 24, 40, 0.96);
backdrop-filter: blur(20px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 20px 24px;
width: 90%;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
touch-action: auto;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.9);
color: #e0e0e0;
font-family: 'Segoe UI', sans-serif;
}
/* Settings panel header */
.settings-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.settings-panel .panel-header h2 {
font-size: 14px;
font-weight: 600;
color: #fbbf24;
}
.settings-panel .close-btn {
background: rgba(255, 255, 255, 0.04);
border: none;
color: rgba(255, 255, 255, 0.2);
font-size: 16px;
cursor: pointer;
padding: 4px 10px;
border-radius: 4px;
touch-action: auto;
}
/* Settings panel tabs */
.settings-panel .panel-tabs {
display: flex;
gap: 6px;
margin-bottom: 14px;
}
.settings-panel .panel-tabs .panel-tab {
flex: 1;
padding: 8px 10px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.3);
touch-action: auto;
}
.settings-panel .panel-tabs .panel-tab.active {
background: rgba(167, 139, 250, 0.18);
color: #a78bfa;
}
/* Tab content */
.settings-panel .tab-content {
display: none;
}
.settings-panel .tab-content.active {
display: block;
}
.settings-panel .panel-content {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Setting sections */
.settings-panel .setting-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-panel .section-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
letter-spacing: 1px;
}
/* Button groups */
.settings-panel .btn-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.settings-panel .btn-group button {
padding: 8px 14px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
touch-action: auto;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.4);
}
.settings-panel .btn-group button.primary {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.settings-panel .btn-group button.danger {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
/* Trackpad */
.settings-panel .trackpad {
width: 100%;
height: 60px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
touch-action: none;
cursor: grab;
overflow: hidden;
}
.settings-panel .trackpad .dot {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(167, 139, 250, 0.3);
border: 2px solid rgba(167, 139, 250, 0.5);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
display: none;
}
.settings-panel .trackpad .center-mark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 50%;
pointer-events: none;
}
.settings-panel .trackpad .hint {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
font-size: 6px;
color: rgba(255, 255, 255, 0.05);
pointer-events: none;
white-space: nowrap;
}
/* Slider container */
.settings-panel .slider-container {
display: flex;
align-items: center;
gap: 8px;
}
.settings-panel .slider-container input[type="range"] {
flex: 1;
accent-color: #a78bfa;
height: 4px;
cursor: pointer;
}
.settings-panel .slider-value {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
min-width: 35px;
text-align: right;
}
/* Saved routes list */
.saved-rutas-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.saved-ruta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
gap: 6px;
position: relative;
overflow: hidden;
touch-action: auto;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.saved-ruta-item .delete-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: #f44336;
width: 0%;
transition: width 0.1s linear;
}
.saved-ruta-name {
font-size: 12px;
color: #e0e0e0;
flex: 1;
pointer-events: none;
}
/* Center pin section */
.chincheta-row {
display: flex;
gap: 8px;
align-items: stretch;
}
.coords-column {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.coord-input {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: white;
font-size: 12px;
}
.chincheta-actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.btn-toggle-chincheta {
padding: 6px 10px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
touch-action: auto;
}
.btn-toggle-chincheta.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
border-color: rgba(76, 175, 80, 0.3);
}
.btn-add-coords {
padding: 6px 10px;
background: rgba(255, 165, 0, 0.2);
color: #ffa500;
border: 1px solid rgba(255, 165, 0, 0.3);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
touch-action: auto;
} .\apps\maps\client\tv.js 17015 caracteres // TV side. Routes map using Leaflet
let L;
async function loadLeaflet() {
if (L) return L;
await new Promise((resolve) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css';
link.onload = resolve;
document.head.appendChild(link);
});
const module = await import('https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet-src.esm.js');
L = module.default || module;
return L;
}
export async function mount(container, sdk) {
// Load CSS
const cssId = 'maps-tv-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/maps/client/tv/tv.css`;
document.head.appendChild(link);
}
// Load HTML template
const response = await fetch(`/apps/maps/client/tv/tv.html`);
const html = await response.text();
container.innerHTML = html;
const lastAction = container.querySelector('#lastAction');
const contentContainer = container.querySelector('#contentContainer');
const mapWrapper = container.querySelector('#map-wrapper');
// Rotación actual. Se inicializa desde el servidor cuando
// llega el primer initial_state / recover_session, y se
// actualiza cada vez que llega un rotate_map.
let currentRotation = 0;
function applyRotation(rotation) {
currentRotation = rotation || 0;
if (mapWrapper) {
mapWrapper.style.transform =
`translate(-50%,-50%) rotate(${currentRotation}deg)`;
}
}
try {
const Leaflet = await loadLeaflet();
function calculateSquareSize() {
const w = contentContainer.clientWidth;
const h = contentContainer.clientHeight;
return Math.ceil(Math.sqrt(w * w + h * h)) + 20;
}
function adjustSquare() {
const size = calculateSquareSize();
mapWrapper.style.width = `${size}px`;
mapWrapper.style.height = `${size}px`;
}
adjustSquare();
const map = Leaflet.map('map').setView([40.4168, -3.7038], 6);
Leaflet.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
maxZoom: 19
}).addTo(map);
setTimeout(() => { map.invalidateSize(); }, 100);
window.addEventListener('resize', () => {
adjustSquare();
map.invalidateSize();
});
const routesOnMap = new Map();
const highlightedPoints = new Map();
let pinMarker = null;
let pinVisible = false;
function highlightPoint(routeId, pointIndex, highlighted) {
const routeInfo = routesOnMap.get(routeId);
if (!routeInfo || !routeInfo.markers || !routeInfo.markers[pointIndex]) return;
const marker = routeInfo.markers[pointIndex];
const key = `${routeId}_${pointIndex}`;
if (highlighted) {
if (!highlightedPoints.has(key)) {
highlightedPoints.set(key, {
radius: marker.options.radius,
weight: marker.options.weight,
opacity: marker.options.opacity,
fillOpacity: marker.options.fillOpacity
});
}
marker.setStyle({
radius: 12,
weight: 3,
opacity: 1,
fillOpacity: 0.8
});
if (!marker._pulseInterval) {
marker._pulseInterval = setInterval(() => {
const currentRadius = marker.getRadius();
marker.setRadius(currentRadius === 12 ? 10 : 12);
}, 300);
}
marker.bringToFront();
} else {
const originalStyle = highlightedPoints.get(key);
if (originalStyle) {
marker.setStyle(originalStyle);
if (marker._pulseInterval) {
clearInterval(marker._pulseInterval);
marker._pulseInterval = null;
}
highlightedPoints.delete(key);
}
}
}
map.on('move', () => {
if (pinVisible && pinMarker) {
const center = map.getCenter();
pinMarker.setLatLng([center.lat, center.lng]);
}
});
async function drawRoadSegment(pointA, pointB, color, routeId) {
try {
const coordinates = `${pointA.lng},${pointA.lat};${pointB.lng},${pointB.lat}`;
const response = await fetch(`https://router.project-osrm.org/route/v1/driving/${coordinates}?overview=full&geometries=geojson`);
const data = await response.json();
if (data.code === 'Ok' && data.routes && data.routes[0]) {
const roadRoute = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
const polyline = Leaflet.polyline(roadRoute, {
color: color,
weight: 5
})
const routeInfo = routesOnMap.get(routeId);
if (routeInfo && routeInfo.route && routeInfo.route.showLines !== false) {
polyline.addTo(map);
}
polyline._routeId = routeId;
if (routeInfo) {
routeInfo.polylines.push(polyline);
}
}
} catch (error) {
const polyline = Leaflet.polyline(
[[pointA.lat, pointA.lng], [pointB.lat, pointB.lng]],
{ color: color, weight: 5 }
).addTo(map);
polyline._routeId = routeId;
const routeInfo = routesOnMap.get(routeId);
if (routeInfo) {
routeInfo.polylines.push(polyline);
}
}
}
function drawRoute(route) {
const markers = [];
// Draw markers
route.points.forEach((point, index) => {
const marker = Leaflet.circleMarker([point.lat, point.lng], {
radius: 6,
color: route.color,
fillColor: route.color,
fillOpacity: route.visible ? 1 : 0,
opacity: route.visible ? 1 : 0
});
if (route.showMarkers !== false) {
marker.addTo(map);
}
marker.bindPopup(`${route.name} - Point ${index + 1}${point.name ? ': ' + point.name : ''}`);
markers.push(marker);
});
// Draw segments
const polylines = [];
for (let i = 0; i < route.points.length - 1; i++) {
const pointA = route.points[i];
const pointB = route.points[i + 1];
let useRoad = route.byRoad;
if (pointB.invertType) {
useRoad = !useRoad;
}
if (useRoad) {
drawRoadSegment(pointA, pointB, route.color, route.id);
} else {
const polyline = Leaflet.polyline(
[[pointA.lat, pointA.lng], [pointB.lat, pointB.lng]],
{ color: route.color, weight: 5 }
);
polyline.addTo(map);
polyline._routeId = route.id;
polylines.push(polyline);
}
}
routesOnMap.set(route.id, {
polylines: polylines,
markers: markers,
route: route
});
if (route.showLines === false) {
const storedRoute = routesOnMap.get(route.id);
if (storedRoute && storedRoute.polylines) {
storedRoute.polylines.forEach(line => {
map.removeLayer(line);
});
}
}
}
function removeRoute(routeId) {
const routeInfo = routesOnMap.get(routeId);
if (routeInfo) {
if (routeInfo.polylines) {
routeInfo.polylines.forEach(p => {
if (map.hasLayer(p)) {
map.removeLayer(p);
}
});
}
map.eachLayer((layer) => {
if (layer instanceof Leaflet.Polyline && layer._routeId === routeId) {
map.removeLayer(layer);
}
});
if (routeInfo.markers) {
routeInfo.markers.forEach(m => {
if (map.hasLayer(m)) {
map.removeLayer(m);
}
});
}
routesOnMap.delete(routeId);
}
}
function toggleVisibility(route) {
const routeInfo = routesOnMap.get(route.id);
if (routeInfo) {
if (routeInfo.polylines) {
routeInfo.polylines.forEach(p => {
p.setStyle({
opacity: route.visible ? 1 : 0,
dashArray: route.visible ? null : '10, 10'
});
});
}
if (routeInfo.markers) {
routeInfo.markers.forEach(m => {
m.setStyle({
opacity: route.visible ? 1 : 0,
fillOpacity: route.visible ? 1 : 0
});
});
}
}
}
const unsubscribe = sdk.onApp((data) => {
switch(data.type) {
case 'initial_state':
data.routes.forEach(route => drawRoute(route));
// Sincronizamos la rotación desde el servidor.
if (typeof data.rotation === 'number') {
applyRotation(data.rotation);
}
lastAction.textContent = data.message || 'Map ready';
break;
case 'recover_session':
// Por si la TV se reconecta y el servidor le manda
// este mensaje en lugar de initial_state.
if (typeof data.rotation === 'number') {
applyRotation(data.rotation);
}
break;
case 'route_created':
drawRoute(data.route);
lastAction.textContent = `${data.route.name} created`;
break;
case 'route_updated':
removeRoute(data.route.id);
drawRoute(data.route);
lastAction.textContent = `${data.route.name} updated`;
break;
case 'route_deleted':
removeRoute(data.routeId);
lastAction.textContent = 'Route deleted';
break;
case 'route_visibility_changed':
toggleVisibility(data.route);
break;
case 'all_routes_deleted':
routesOnMap.forEach((info) => {
if (info.polylines) info.polylines.forEach(p => map.removeLayer(p));
if (info.markers) info.markers.forEach(m => map.removeLayer(m));
});
routesOnMap.clear();
lastAction.textContent = 'All routes deleted';
break;
case 'route_loaded_tv':
const loadedRoute = {
...data.route,
showMarkers: data.route.showMarkers !== undefined ? data.route.showMarkers : true,
showLines: data.route.showLines !== undefined ? data.route.showLines : true
};
drawRoute(loadedRoute);
lastAction.textContent = `${data.route.name} loaded`;
break;
case 'toggle_pin':
pinVisible = data.visible;
if (pinVisible) {
const center = map.getCenter();
pinMarker = Leaflet.circleMarker([center.lat, center.lng], {
radius: 10, color: '#ffa500', fillColor: '#ffa500', fillOpacity: 0.3, opacity: 1, dashArray: '5, 5'
}).addTo(map);
} else {
if (pinMarker) { map.removeLayer(pinMarker); pinMarker = null; }
}
break;
case 'highlight_point':
highlightPoint(data.routeId, data.pointIndex, data.highlighted);
break;
case 'toggle_route_markers':
const markersInfo = routesOnMap.get(data.routeId);
if (markersInfo && markersInfo.markers) {
markersInfo.markers.forEach(marker => {
if (data.show) { marker.addTo(map); } else { map.removeLayer(marker); }
});
}
break;
case 'toggle_route_lines':
const linesInfo = routesOnMap.get(data.routeId);
if (linesInfo && linesInfo.polylines) {
linesInfo.polylines.forEach(line => {
if (data.show) { line.addTo(map); } else { map.removeLayer(line); }
});
}
break;
case 'zoom_to_route':
const zoomInfo = routesOnMap.get(data.routeId);
if (zoomInfo) {
const w = contentContainer.clientWidth;
const h = contentContainer.clientHeight;
const diagonal = Math.sqrt(w * w + h * h);
const extraPadding = Math.floor((diagonal - Math.min(w, h)) / 2) + 50;
if (zoomInfo.polylines && zoomInfo.polylines.length > 0) {
const bounds = Leaflet.latLngBounds(zoomInfo.polylines.map(p => p.getBounds()));
map.fitBounds(bounds, { padding: [extraPadding, extraPadding] });
} else if (zoomInfo.markers && zoomInfo.markers.length > 0) {
const bounds = Leaflet.latLngBounds(zoomInfo.markers.map(m => m.getLatLng()));
map.fitBounds(bounds, { padding: [extraPadding, extraPadding] });
}
}
break;
case 'request_map_center':
const center = map.getCenter();
sdk.sendApp({
type: 'map_center',
center: { lat: center.lat, lng: center.lng },
targetPlayerId: data.playerId
});
break;
case 'move_map':
map.panBy([data.deltaX || 0, data.deltaY || 0]);
break;
case 'zoom_map':
map.setZoom(map.getZoom() + (data.deltaZoom || 0));
break;
case 'rotate_map':
// Aplicamos la rotación que envía el servidor.
// Ya no dependemos de que el mensaje venga
// acompañado de un valor distinto: si llega,
// lo aplicamos.
if (typeof data.rotation === 'number') {
applyRotation(data.rotation);
}
break;
case 'reset_map':
map.setView([40.4168, -3.7038], 6);
// El servidor envía rotation:0 en el reset.
applyRotation(typeof data.rotation === 'number' ? data.rotation : 0);
break;
}
});
lastAction.textContent = 'Map ready';
return () => {
unsubscribe();
window.removeEventListener('resize', () => {});
map.remove();
};
} catch (error) {
console.error('Error loading map:', error);
lastAction.textContent = 'Error: ' + error.message;
return () => {};
}
} .\apps\paint\server.js 22116 caracteres // Paint colaborativo.
// El Core gestiona sala, jugadores, perfiles, conexiones y roles.
// Esta app sólo gestiona el estado propio del dibujo.
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
// ============================================================
// BASE DE DATOS: BACKUPS DEL TIMELINE
// ============================================================
const BACKUP_RETENTION = 50;
const db = new sqlite3.Database(
path.join(__dirname, 'backups.db')
);
db.run(`
CREATE TABLE IF NOT EXISTS history_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
history_index INTEGER NOT NULL,
total INTEGER NOT NULL,
events TEXT NOT NULL
)
`);
// ============================================================
// ESTADO DE LA APP
// ============================================================
let history = [];
let currentIndex = -1;
let canvasWidth = 1920;
let canvasHeight = 1080;
let canvasOffsetX = 0;
let canvasOffsetY = 0;
let canvasScale = 1;
// Modo de tamaño del canvas:
// 'auto' -> se elige el tamaño al primer viewport recibido
// (preferencia: TV si hay; si no, admin móvil).
// 'tv' -> fuerza el tamaño de la TV.
// 'admin' -> fuerza el tamaño del móvil del admin.
let canvasSizeMode = 'auto';
// Si ya se ha fijado un tamaño, no cambia solo.
let canvasSizeLocked = false;
// Último viewport reportado por cada jugador.
// Map: playerId -> { width, height, role, isAdmin }
const lastViewportByPlayer = new Map();
// Por cada jugador, lista ordenada de actionId.
let userActionOrder = {};
// Evita backups repetidos del mismo timeline.
let historyBackedUp = false;
// ============================================================
// HELPERS
// ============================================================
function getConnectedTvViewport() {
for (const vp of lastViewportByPlayer.values()) {
if (vp.role === 'tv') {
return vp;
}
}
return null;
}
function getConnectedAdminViewport() {
for (const vp of lastViewportByPlayer.values()) {
if (vp.role === 'mobile' && vp.isAdmin) {
return vp;
}
}
return null;
}
// ============================================================
// BACKUPS
// ============================================================
function backupHistorySnapshot() {
const createdAt = new Date().toISOString();
const eventsJson = JSON.stringify(history);
db.run(
`
INSERT INTO history_backups
(
created_at,
history_index,
total,
events
)
VALUES (?, ?, ?, ?)
`,
[
createdAt,
currentIndex,
history.length,
eventsJson
],
err => {
if (err) {
console.log(
'⚠️ No se pudo guardar el backup del timeline:',
err.message
);
return;
}
db.run(
`
DELETE FROM history_backups
WHERE id NOT IN (
SELECT id
FROM history_backups
ORDER BY id DESC
LIMIT ?
)
`,
[BACKUP_RETENTION]
);
}
);
}
function listBackups(callback) {
db.all(
`
SELECT
id,
created_at,
history_index,
total
FROM history_backups
ORDER BY id DESC
LIMIT ?
`,
[BACKUP_RETENTION],
(err, rows) => {
if (err) {
console.log(
'⚠️ No se pudo listar los backups:',
err.message
);
callback([]);
return;
}
callback(rows);
}
);
}
// ============================================================
// HISTORIAL
// ============================================================
function setHistoryIndex(ctx, index) {
if (index < -1 || index >= history.length) {
return;
}
if (index === currentIndex) {
return;
}
currentIndex = index;
if (!historyBackedUp) {
backupHistorySnapshot();
historyBackedUp = true;
}
ctx.broadcast({
type: 'history_index',
index: currentIndex,
total: history.length
});
}
function addPointToHistory(ctx, point) {
// Si estábamos en mitad del timeline,
// eliminar la rama posterior.
if (currentIndex < history.length - 1) {
history = history.slice(0, currentIndex + 1);
}
history.push(point);
currentIndex = history.length - 1;
historyBackedUp = false;
ctx.broadcast({
type: 'new_point',
point,
total: history.length,
current: currentIndex
});
}
// ============================================================
// UNDO
// ============================================================
function trackAction(ctx, userId, actionId) {
if (!actionId) {
return;
}
if (!userActionOrder[userId]) {
userActionOrder[userId] = [];
}
const arr = userActionOrder[userId];
if (arr[arr.length - 1] !== actionId) {
arr.push(actionId);
}
sendUndoState(ctx, userId);
}
function sendUndoState(ctx, userId) {
const canUndo = !!(
userActionOrder[userId] &&
userActionOrder[userId].length > 0
);
ctx.sendTo(userId, {
type: 'undo_state',
canUndo
});
}
function undoLastActionForUser(ctx, userId) {
const arr = userActionOrder[userId];
if (!arr || arr.length === 0) {
return false;
}
const actionId = arr.pop();
const before = history.length;
history = history.filter(
ev =>
!(
ev.userId === userId &&
ev.actionId === actionId
)
);
const removed = before - history.length;
if (currentIndex >= history.length) {
currentIndex = history.length - 1;
}
historyBackedUp = false;
ctx.broadcast({
type: 'history_replaced',
events: history,
total: history.length,
current: currentIndex
});
sendUndoState(ctx, userId);
return removed > 0;
}
function rebuildUserActionOrder() {
userActionOrder = {};
history.forEach(ev => {
if (!ev.actionId || ev.userId === undefined) {
return;
}
const arr =
userActionOrder[ev.userId] ||
(userActionOrder[ev.userId] = []);
if (arr[arr.length - 1] !== ev.actionId) {
arr.push(ev.actionId);
}
});
}
// ============================================================
// RESTAURAR BACKUP
// ============================================================
function restoreBackup(ctx, backupId, callback) {
db.get(
`
SELECT *
FROM history_backups
WHERE id = ?
`,
[backupId],
(err, row) => {
if (err || !row) {
callback(false);
return;
}
if (!historyBackedUp) {
backupHistorySnapshot();
}
try {
history = JSON.parse(row.events);
} catch (_) {
callback(false);
return;
}
currentIndex = Math.min(
row.history_index,
history.length - 1
);
historyBackedUp = true;
rebuildUserActionOrder();
ctx.broadcast({
type: 'history_replaced',
events: history,
total: history.length,
current: currentIndex
});
ctx.players.forEach(player => {
sendUndoState(ctx, player.id);
});
callback(true);
}
);
}
// ============================================================
// ESTADO INICIAL DE PAINT
// ============================================================
function paintInit() {
return {
type: 'paint_init',
events: history,
total: history.length,
current: currentIndex,
canvas: {
width: canvasWidth,
height: canvasHeight,
offsetX: canvasOffsetX,
offsetY: canvasOffsetY,
scale: canvasScale
}
};
}
function sendInitTo(ctx, playerId) {
ctx.sendTo(playerId, paintInit());
sendUndoState(ctx, playerId);
}
// ============================================================
// CONTRATO ACTUAL DE APP
// ============================================================
module.exports = function createApp(ctx) {
return {
// ====================================================
// START
// ====================================================
async onStart() {
history = [];
currentIndex = -1;
canvasWidth = 1920;
canvasHeight = 1080;
canvasOffsetX = 0;
canvasOffsetY = 0;
canvasScale = 1;
canvasSizeMode = 'auto';
canvasSizeLocked = false;
lastViewportByPlayer.clear();
userActionOrder = {};
historyBackedUp = false;
ctx.broadcast(paintInit());
},
// ====================================================
// PLAYER JOINED
// ====================================================
async onPlayerJoined(player) {
sendInitTo(ctx, player.id);
},
// ====================================================
// PLAYER CONNECTED
// ====================================================
async onPlayerConnected(player) {
sendInitTo(ctx, player.id);
},
// ====================================================
// PLAYER DISCONNECTED
// ====================================================
async onPlayerDisconnected(player) {
// Nada por ahora.
},
// ====================================================
// PLAYER LEFT
// ====================================================
async onPlayerLeft(player) {
delete userActionOrder[player.id];
lastViewportByPlayer.delete(player.id);
if (ctx.players.length === 0) {
history = [];
currentIndex = -1;
canvasOffsetX = 0;
canvasOffsetY = 0;
canvasScale = 1;
canvasSizeMode = 'auto';
canvasSizeLocked = false;
lastViewportByPlayer.clear();
userActionOrder = {};
historyBackedUp = false;
}
},
// ====================================================
// MESSAGES
// ====================================================
async onMessage(data, player) {
const playerId = player.id;
const isAdmin = player.isAdmin;
const isViewer = player.role === 'tv';
// ================================================
// VIEWPORT
// ================================================
if (data.type === 'viewport') {
lastViewportByPlayer.set(playerId, {
width: data.width,
height: data.height,
role: player.role,
isAdmin: player.isAdmin,
});
// Si el tamaño no está fijado y estamos en modo auto,
// elegimos el tamaño con la regla:
// - Si hay TV conectada -> tamaño TV.
// - Si no hay TV -> tamaño del admin (móvil).
if (!canvasSizeLocked && canvasSizeMode === 'auto') {
const anyTvConnected = ctx.players.some(
p => p.role === 'tv'
);
if (anyTvConnected) {
const tvViewport = getConnectedTvViewport();
if (tvViewport) {
canvasWidth = tvViewport.width;
canvasHeight = tvViewport.height;
canvasSizeLocked = true;
ctx.broadcast(paintInit());
}
} else {
const adminViewport = getConnectedAdminViewport();
if (adminViewport) {
canvasWidth = adminViewport.width;
canvasHeight = adminViewport.height;
canvasSizeLocked = true;
ctx.broadcast(paintInit());
}
}
}
// Siempre mandamos paintInit al jugador que reporta,
// para que tenga el estado actual.
ctx.sendTo(playerId, paintInit());
return;
}
// ================================================
// DRAW POINT
// ================================================
if (data.type === 'draw_point') {
if (isViewer) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Los viewers no pueden dibujar'
});
return;
}
const point = {
...data.point,
userId: playerId
};
trackAction(ctx, playerId, point.actionId);
addPointToHistory(ctx, point);
return;
}
// ================================================
// FILL
// ================================================
if (data.type === 'fill') {
if (isViewer) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Los viewers no pueden rellenar'
});
return;
}
const fillActionId =
data.actionId ||
`fill_${playerId}_${Date.now()}`;
const fillData = {
type: 'fill',
x: data.x,
y: data.y,
color: data.color,
userId: playerId,
actionId: fillActionId
};
trackAction(ctx, playerId, fillActionId);
addPointToHistory(ctx, fillData);
return;
}
// ================================================
// CLEAR
// ================================================
if (data.type === 'clear') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede borrar'
});
return;
}
history = [];
currentIndex = -1;
userActionOrder = {};
historyBackedUp = false;
ctx.broadcast({ type: 'clear' });
ctx.players.forEach(p => {
sendUndoState(ctx, p.id);
});
return;
}
// ================================================
// UNDO OWN ACTION
// ================================================
if (data.type === 'undo_own_action') {
if (isViewer) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Los viewers no pueden deshacer'
});
return;
}
const ok = undoLastActionForUser(ctx, playerId);
if (!ok) {
ctx.sendTo(playerId, {
type: 'error',
message: 'No tienes nada que deshacer'
});
}
return;
}
// ================================================
// SET HISTORY INDEX
// ================================================
if (data.type === 'set_history_index') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede controlar el timeline'
});
return;
}
setHistoryIndex(ctx, data.index);
return;
}
// ================================================
// LIST BACKUPS
// ================================================
if (data.type === 'list_backups') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede ver los backups'
});
return;
}
listBackups(backups => {
ctx.sendTo(playerId, {
type: 'backups_list',
backups
});
});
return;
}
// ================================================
// RESTORE BACKUP
// ================================================
if (data.type === 'restore_backup') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede restaurar un backup'
});
return;
}
restoreBackup(ctx, data.id, ok => {
if (!ok) {
ctx.sendTo(playerId, {
type: 'error',
message: 'No se pudo restaurar ese backup'
});
}
});
return;
}
// ================================================
// MOVE CANVAS
// ================================================
if (data.type === 'move_canvas') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede mover el lienzo'
});
return;
}
canvasOffsetX = data.offsetX;
canvasOffsetY = data.offsetY;
canvasScale = data.scale;
ctx.broadcast({
type: 'canvas_moved',
offsetX: canvasOffsetX,
offsetY: canvasOffsetY,
scale: canvasScale
});
return;
}
// ================================================
// SET CANVAS SIZE
// ================================================
if (data.type === 'set_canvas_size') {
if (!isAdmin) {
ctx.sendTo(playerId, {
type: 'error',
message: 'Solo el admin puede cambiar el tamaño'
});
return;
}
const mode = data.mode;
if (mode !== 'auto' && mode !== 'tv' && mode !== 'admin') {
return;
}
canvasSizeMode = mode;
if (mode === 'auto') {
canvasSizeLocked = false;
// El siguiente viewport fijará el tamaño.
return;
}
if (mode === 'tv') {
const tvViewport = getConnectedTvViewport();
if (tvViewport) {
canvasWidth = tvViewport.width;
canvasHeight = tvViewport.height;
} else {
// No hay TV. Ponemos el tamaño estándar.
canvasWidth = 1920;
canvasHeight = 1080;
}
canvasSizeLocked = true;
}
if (mode === 'admin') {
const vp = lastViewportByPlayer.get(playerId);
canvasWidth = data.width || (vp ? vp.width : 1080);
canvasHeight = data.height || (vp ? vp.height : 1920);
canvasSizeLocked = true;
}
// Resetear offset/scale al cambiar el tamaño.
canvasOffsetX = 0;
canvasOffsetY = 0;
canvasScale = 1;
ctx.broadcast({
type: 'canvas_resized',
width: canvasWidth,
height: canvasHeight
});
ctx.broadcast(paintInit());
return;
}
},
// ====================================================
// STOP
// ====================================================
async onStop() {
await new Promise(resolve => {
db.close(() => resolve());
});
}
};
}; .\public\tv.html 23129 caracteres <!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Core TV</title>
<meta name="darkreader-lock" content="true" />
<meta name="color-scheme" content="dark" />
<style>
:root {
color-scheme: dark only;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #050505;
color: #fff;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
#app,
.tv-app,
.tv-app-content {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.tv-menu {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #0a0a0a, #070707);
}
.tv-menu-header {
flex: 0 0 64px;
display: flex;
align-items: center;
padding: 0 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.tv-menu-footer {
flex: 0 0 42px;
display: flex;
align-items: center;
padding: 0 28px;
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
.tv-menu-header-content,
.tv-menu-footer-content {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
}
.tv-status-label {
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.35);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tv-player-list,
.tv-screen-list {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.tv-player,
.tv-screen {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.78);
font-size: 15px;
white-space: nowrap;
}
.tv-player.admin,
.tv-screen.main {
background: rgba(255, 215, 0, 0.09);
color: rgba(255, 255, 255, 0.92);
}
.tv-screen {
padding: 5px 9px;
border-radius: 7px;
font-size: 14px;
}
.tv-screen.self {
border: 2px solid rgba(255, 255, 255, 0.85);
padding: 3px 7px;
}
.tv-tree-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tv-tree {
position: absolute;
top: 0;
left: 0;
height: 100%;
display: flex;
transition:
transform 350ms cubic-bezier(0.22, 1, 0.36, 1);
}
.tv-column {
flex: 0 0 var(--tv-column-width);
width: var(--tv-column-width);
height: 100%;
padding: 24px 22px;
overflow: hidden;
border-right: 1px solid rgba(255, 255, 255, 0.055);
}
.tv-column-title {
width: 100%;
margin-bottom: 16px;
overflow: hidden;
color: rgba(255, 255, 255, 0.36);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.06em;
white-space: nowrap;
text-overflow: ellipsis;
}
.tv-column-items {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.tv-section {
width: 100%;
margin-bottom: 20px;
}
.tv-section:last-child {
margin-bottom: 0;
}
.tv-section .tv-column-title {
margin-bottom: 10px;
}
.tv-item {
width: 100%;
min-height: 58px;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
overflow: hidden;
border-radius: 10px;
background: rgba(255, 255, 255, 0.035);
color: rgba(255, 255, 255, 0.88);
}
.tv-item.app {
background: rgba(255, 255, 255, 0.045);
}
.tv-item.folder {
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.tv-item.folder.active {
background: rgba(255, 215, 0, 0.12);
border-color: rgba(255, 215, 0, 0.35);
box-shadow:
0 0 0 1px rgba(255, 215, 0, 0.08),
0 0 24px rgba(255, 215, 0, 0.08);
color: rgba(255, 255, 255, 0.98);
}
.tv-item.folder.active .tv-item-icon {
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
}
.tv-item.folder.active .tv-item-name {
font-weight: 650;
}
.tv-item.app.wanted {
box-shadow: inset 0 0 0 2px rgba(251, 191, 36, 0.5);
}
.tv-item-icon {
flex: 0 0 34px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: 25px;
}
.tv-item-name {
min-width: 0;
overflow: hidden;
font-size: 18px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.tv-item-wants {
display: flex;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.tv-want-badge {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
font-size: 12px;
padding: 2px 8px;
border-radius: 8px;
white-space: nowrap;
}
.tv-empty {
padding: 12px 4px;
color: rgba(255, 255, 255, 0.2);
font-size: 15px;
}
.tv-empty-root {
grid-column: 1 / -1;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.25);
font-size: 18px;
text-align: center;
}
.tv-app-grid {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, 180px);
grid-auto-rows: 180px;
gap: 18px;
padding: 24px 28px;
overflow: auto;
justify-content: center;
align-content: center;
}
.tv-app-grid .tv-item {
height: 100%;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 20px;
}
.tv-app-grid .tv-item-icon {
width: 64px;
height: 64px;
font-size: 48px;
}
.tv-app-grid .tv-item-name {
width: 100%;
text-align: center;
font-size: 18px;
}
.tv-app-grid .tv-item-wants {
position: absolute;
bottom: 6px;
left: 6px;
right: 6px;
justify-content: center;
margin-left: 0;
}
.tv-app-grid .tv-item {
position: relative;
}
.tv-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #ff5555;
text-align: center;
}
.tv-error-box {
max-width: 700px;
}
.tv-error-title {
margin-bottom: 12px;
font-size: 28px;
font-weight: 600;
}
.tv-error-message {
color: rgba(255, 255, 255, 0.55);
font-size: 16px;
}
.tv-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.25);
font-size: 18px;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import { connectPlatform, mountApp } from "/sdk.js";
const sessionId = localStorage.getItem("platform_session_id");
const sdk = connectPlatform("tv", undefined, sessionId);
let currentUnmount = null;
let activeFolderId = null;
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
const getLayout = () =>
sdk.state.appLayout || { version: 1, root: [] };
const getAppsMap = () =>
new Map((sdk.state.apps || []).map((app) => [app.id, app]));
function findFolder(folderId, items = getLayout().root, parent = null) {
for (const item of items || []) {
if (item.type !== "folder") continue;
if (item.id === folderId) {
return { folder: item, parent };
}
const result = findFolder(folderId, item.children || [], item);
if (result) return result;
}
return null;
}
function getFolderPath(folderId) {
const result = [];
let currentId = folderId;
while (currentId) {
const folder = findFolder(currentId);
if (!folder) break;
result.unshift(folder.folder);
currentId = folder.parent?.id || null;
}
return result;
}
const getChildFolders = (folder) =>
(folder?.children || []).filter((item) => item.type === "folder");
const getDescendantFolders = (folders) =>
folders.flatMap(getChildFolders);
function getColumnWidth(columnCount) {
if (columnCount <= 1) return 100;
if (columnCount === 2) return 50;
if (columnCount === 3) return 100 / 3;
return 25;
}
function getFolderColumn(folders) {
return {
id: `folders-${folders.map((folder) => folder.id).join("-")}`,
name: "",
items: [],
sections: folders.map((folder) => ({
id: folder.id,
name: getFolderPath(folder.id)
.map((item) => `/${item.name}`)
.join(""),
items: folder.children || [],
})),
};
}
function getAllColumns(rootItems = null) {
const items = rootItems || getLayout().root || [];
const result = [
{
id: rootItems ? "context-root" : "root",
name: rootItems ? "" : "/",
items,
sections: [],
},
];
let folders = items.filter((item) => item.type === "folder");
while (folders.length) {
result.push(getFolderColumn(folders));
folders = getDescendantFolders(folders);
}
return result;
}
function getActiveContext() {
if (!activeFolderId) {
return { columns: getAllColumns(), offset: 0, highlight: false };
}
const result = findFolder(activeFolderId);
if (!result) {
activeFolderId = null;
return { columns: getAllColumns(), offset: 0, highlight: false };
}
const folder = result.folder;
const columns = [
{
id: `active-root-${folder.id}`,
name: getFolderPath(folder.id)
.map((item) => `/${item.name}`)
.join(""),
items: folder.children || [],
sections: [],
},
];
let folders = getChildFolders(folder);
while (folders.length > 0) {
columns.push(getFolderColumn(folders));
folders = getDescendantFolders(folders);
}
return { columns, offset: 0, highlight: false };
}
function renderItem(item, appsMap, highlightActiveFolder) {
if (!item) return "";
if (item.type === "app") {
const app = appsMap.get(item.id);
if (!app) return "";
const wants = (sdk.state.appWants || {})[item.id] || [];
const wantsHtml = wants.length > 0
? `<div class="tv-item-wants">
${wants
.map(
(w) =>
`<span class="tv-want-badge">${escapeHtml(w.name)}</span>`,
)
.join("")}
</div>`
: "";
const wantedClass = wants.length > 0 ? "wanted" : "";
return `
<div class="tv-item app ${wantedClass}">
<div class="tv-item-icon">${escapeHtml(app.icon || "🎮")}</div>
<div class="tv-item-name">${escapeHtml(app.name || app.id)}</div>
${wantsHtml}
</div>
`;
}
if (item.type === "folder") {
const active =
item.active && highlightActiveFolder ? "active" : "";
return `
<div class="tv-item folder ${active}">
<div class="tv-item-icon">📁</div>
<div class="tv-item-name">${escapeHtml(item.name)}</div>
</div>
`;
}
return "";
}
function renderColumn(column, appsMap, highlight) {
const content = column.sections.length
? column.sections
.map(
(section) => `
<div class="tv-section">
<div class="tv-column-title">${escapeHtml(section.name)}</div>
<div class="tv-column-items">
${
section.items.length
? section.items
.map((item) =>
renderItem(item, appsMap, highlight),
)
.join("")
: `<div class="tv-empty">Vacío</div>`
}
</div>
</div>
`,
)
.join("")
: column.items.length
? column.items
.map((item) => renderItem(item, appsMap, highlight))
.join("")
: `<div class="tv-empty">Vacío</div>`;
return `
<div class="tv-column">
${
column.name
? `<div class="tv-column-title">${escapeHtml(column.name)}</div>`
: ""
}
<div class="tv-column-items">${content}</div>
</div>
`;
}
function renderTreeHtml(columns, appsMap, highlight) {
return columns
.map((column) => renderColumn(column, appsMap, highlight))
.join("");
}
function renderPlayers() {
const players = (sdk.state.players || []).filter(
(player) => player.role === "mobile",
);
const content = players.length
? players
.map(
(player) => `
<div class="tv-player ${player.isAdmin ? "admin" : ""}">
${player.isAdmin ? "👑" : "👤"}
${escapeHtml(player.name || "Jugador")}
</div>
`,
)
.join("")
: `<div class="tv-player">—</div>`;
return `
<div class="tv-status-label">Jugadores</div>
<div class="tv-player-list">${content}</div>
`;
}
function renderScreens() {
const screens = (sdk.state.players || []).filter(
(player) => player.role === "tv",
);
const myConnectionId = sdk.state.playerId;
const content = screens.length
? screens
.map((screen) => {
const main = screen.id === sdk.state.mainScreenId;
const isMe = screen.connectionId === myConnectionId;
return `
<div class="tv-screen ${main ? "main" : ""} ${isMe ? "self" : ""}">
${main ? "👑" : "📺"}
${escapeHtml(screen.name || "TV")}
</div>
`;
})
.join("")
: `<div class="tv-screen">—</div>`;
return `
<div class="tv-status-label">TVs</div>
<div class="tv-screen-list">${content}</div>
`;
}
function updateStatus() {
const players = document.getElementById("tv-players-header");
const screens = document.getElementById("tv-screens-footer");
if (players) players.innerHTML = renderPlayers();
if (screens) screens.innerHTML = renderScreens();
}
function renderMenu() {
const app = document.getElementById("app");
const context = getActiveContext();
const columns = context.columns;
const appsMap = getAppsMap();
if (columns.length === 1) {
const column = columns[0];
const itemsHtml = column.items
.map((item) => renderItem(item, appsMap, false))
.join("");
app.innerHTML = `
<div class="tv-app">
<div class="tv-menu">
<div class="tv-menu-header">
<div id="tv-players-header" class="tv-menu-header-content">
${renderPlayers()}
</div>
</div>
<div class="tv-tree-viewport">
<div class="tv-app-grid">
${
itemsHtml ||
`<div class="tv-empty-root">No hay contenido</div>`
}
</div>
</div>
<div class="tv-menu-footer">
<div id="tv-screens-footer" class="tv-menu-footer-content">
${renderScreens()}
</div>
</div>
</div>
</div>
`;
return;
}
const columnWidth = getColumnWidth(columns.length);
const treeWidth = columns.length * columnWidth;
const treeOffset = context.offset * columnWidth;
const columnsHtml = renderTreeHtml(columns, appsMap, context.highlight);
app.innerHTML = `
<div class="tv-app">
<div class="tv-menu">
<div class="tv-menu-header">
<div id="tv-players-header" class="tv-menu-header-content">
${renderPlayers()}
</div>
</div>
<div class="tv-tree-viewport">
<div
class="tv-tree"
style="
--tv-column-width: ${columnWidth}vw;
width: ${treeWidth}vw;
transform: translateX(-${treeOffset}vw);
"
>
${columnsHtml}
</div>
</div>
<div class="tv-menu-footer">
<div id="tv-screens-footer" class="tv-menu-footer-content">
${renderScreens()}
</div>
</div>
</div>
</div>
`;
}
async function renderApp() {
const appId = sdk.state.currentAppId;
if (currentUnmount) {
try {
currentUnmount();
} catch (error) {
console.error(error);
}
currentUnmount = null;
}
if (!appId) {
renderMenu();
return;
}
const app = sdk.state.apps.find((item) => item.id === appId);
if (!app) {
renderMenu();
return;
}
const container = document.getElementById("app");
container.innerHTML = `
<div class="tv-app">
<div id="tv-app-content" class="tv-app-content"></div>
</div>
`;
try {
currentUnmount = await mountApp(
document.getElementById("tv-app-content"),
app,
"tv",
sdk,
);
} catch (error) {
console.error(error);
document.getElementById("tv-app-content").innerHTML = `
<div class="tv-error">
<div class="tv-error-box">
<div class="tv-error-title">Error cargando la aplicación</div>
<div class="tv-error-message">${escapeHtml(error.message)}</div>
</div>
</div>
`;
}
}
function setActiveFolder(folderId) {
const nextFolderId = folderId || null;
if (nextFolderId === activeFolderId) return;
const tree = document.querySelector(".tv-tree");
if (!tree) {
activeFolderId = nextFolderId;
renderMenu();
return;
}
const currentTransform = getComputedStyle(tree).transform;
let currentOffset = 0;
if (currentTransform && currentTransform !== "none") {
const matrix = new DOMMatrix(currentTransform);
currentOffset = Math.abs(matrix.m41);
}
activeFolderId = nextFolderId;
const context = getActiveContext();
const columns = context.columns;
const columnWidth = getColumnWidth(columns.length);
const targetOffset = context.offset * columnWidth;
tree.style.transition = "none";
tree.style.transform = `translateX(-${currentOffset}vw)`;
tree.style.setProperty("--tv-column-width", `${columnWidth}vw`);
tree.style.width = `${columns.length * columnWidth}vw`;
tree.innerHTML = renderTreeHtml(columns, getAppsMap(), context.highlight);
tree.offsetWidth;
tree.style.transition =
"transform 350ms cubic-bezier(0.22, 1, 0.36, 1)";
requestAnimationFrame(() => {
tree.style.transform = `translateX(-${targetOffset}vw)`;
});
updateStatus();
}
/* =========================================================
GAMEPAD (opt-in por app)
========================================================= */
function updateGamepadForCurrentApp() {
const app = sdk.state.apps.find(
(item) => item.id === sdk.state.currentAppId,
);
if (app?.gamepad?.tv) {
sdk.enableGamepad();
} else {
sdk.disableGamepad();
}
}
/* =========================================================
SDK EVENTS
========================================================= */
sdk.on("init", () => {
activeFolderId = null;
updateGamepadForCurrentApp();
renderApp();
});
sdk.on("app_selected", () => {
activeFolderId = null;
updateGamepadForCurrentApp();
renderApp();
});
sdk.on("app_layout", () => {
if (activeFolderId && !findFolder(activeFolderId)) {
activeFolderId = null;
}
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("apps_list", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("players_changed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("screens_changed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("players_update", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("player_joined", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("player_left", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("player_renamed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("admin_changed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("admin_successor_changed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("main_screen_changed", () => {
if (!sdk.state.currentAppId) {
updateStatus();
}
});
sdk.on("folder_selected", (data) => {
setActiveFolder(data?.folderId);
});
sdk.on("folder_navigation_reset", () => {
setActiveFolder(null);
});
sdk.on("apps_wants_changed", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
if (
sdk.state.currentAppId !== null ||
sdk.state.apps.length > 0
) {
renderApp();
} else {
renderMenu();
}
</script>
</body>
</html> .\apps\dice\client\mobile.js 26513 caracteres // Mobile side. Structure with app top and bottom bars
export async function mount(container, sdk) {
// Load CSS
const cssId = 'dice-mobile-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/dice/client/mobile/mobile.css`;
document.head.appendChild(link);
}
// Load HTML template
const response = await fetch(`/apps/dice/client/mobile/mobile.html`);
const html = await response.text();
container.innerHTML = html;
container.style.overflow = 'hidden';
container.addEventListener('touchstart', (e) => {
if (e.touches.length > 1) {
e.preventDefault();
}
}, {passive: false});
let diceInHand = [];
let selected = new Set();
let diceOnTable = [];
let selectedTable = new Set();
let playerColor = '#a78bfa';
const playerId = sdk.state.playerId;
let confirmDelete = false;
let confirmTimer = null;
let configuration = {
scale: 6,
gravity: 3,
friction: 0.4,
restitution: 1,
mass: 4,
themeColor: '#FF0000',
launchTime: 0
};
const diceHand = container.querySelector('#diceHand');
const tableDiceHand = container.querySelector('#mesaDiceHand');
const counter = container.querySelector('#counter');
const emptyState = container.querySelector('#emptyState');
const tableEmptyState = container.querySelector('#mesaEmptyState');
const btnPickUp = container.querySelector('#btnRecoger');
const btnRoll = container.querySelector('#btnLanzar');
const btnDelete = container.querySelector('#btnEliminar');
const handTotalSum = container.querySelector('#sumaTotalMano');
const tableTotalSum = container.querySelector('#sumaTotalMesa');
const settingsBtn = container.querySelector('#settingsBtn');
const settingsOverlay = container.querySelector('#settingsOverlay');
const closeSettingsBtn = container.querySelector('#closeSettingsBtn');
const colorPicker = container.querySelector('#colorPicker');
const scaleSlider = container.querySelector('#scaleSlider');
const gravitySlider = container.querySelector('#gravitySlider');
const frictionSlider = container.querySelector('#frictionSlider');
const restitutionSlider = container.querySelector('#restitutionSlider');
const massSlider = container.querySelector('#massSlider');
const launchSlider = container.querySelector('#launchSlider');
let isDragging = false;
let hasMoved = false;
let dragStartX = 0;
let dragStartY = 0;
settingsBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
settingsOverlay.classList.add('visible');
}, {passive: false});
settingsBtn.addEventListener('click', (e) => {
e.preventDefault();
settingsOverlay.classList.add('visible');
});
closeSettingsBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
settingsOverlay.classList.remove('visible');
}, {passive: false});
closeSettingsBtn.addEventListener('click', (e) => {
e.preventDefault();
settingsOverlay.classList.remove('visible');
});
function sendConfiguration() {
sdk.sendApp({
type: 'change_configuration',
playerId: playerId,
configuration: configuration
});
}
colorPicker.addEventListener('change', (e) => {
configuration.color = e.target.value;
playerColor = e.target.value;
diceInHand.forEach(die => {
die.color = playerColor;
});
renderDice();
sendConfiguration();
});
scaleSlider.addEventListener('input', (e) => {
configuration.scale = parseFloat(e.target.value);
container.querySelector('#scaleValue').textContent = configuration.scale;
sendConfiguration();
});
gravitySlider.addEventListener('input', (e) => {
configuration.gravity = parseFloat(e.target.value);
container.querySelector('#gravityValue').textContent = configuration.gravity.toFixed(1);
sendConfiguration();
});
frictionSlider.addEventListener('input', (e) => {
configuration.friction = parseFloat(e.target.value);
container.querySelector('#frictionValue').textContent = configuration.friction.toFixed(2);
sendConfiguration();
});
restitutionSlider.addEventListener('input', (e) => {
configuration.restitution = parseFloat(e.target.value);
container.querySelector('#restitutionValue').textContent = configuration.restitution.toFixed(2);
sendConfiguration();
});
massSlider.addEventListener('input', (e) => {
configuration.mass = parseFloat(e.target.value);
container.querySelector('#massValue').textContent = configuration.mass.toFixed(1);
sendConfiguration();
});
launchSlider.addEventListener('input', (e) => {
configuration.launchTime = parseInt(e.target.value);
container.querySelector('#launchValue').textContent = configuration.launchTime;
sendConfiguration();
});
function updateCounter() {
counter.textContent = `${diceInHand.length} dice`;
emptyState.style.display = diceInHand.length === 0 ? 'block' : 'none';
const selectedHandCount = selected.size;
const selectedTableCount = selectedTable.size;
if (selectedTableCount > 0) {
btnRoll.textContent = `🔄 Reroll (${selectedTableCount})`;
} else if (selectedHandCount > 0) {
btnRoll.textContent = `🎲 Roll (${selectedHandCount})`;
} else {
btnRoll.textContent = '🎲 Roll';
}
if (!confirmDelete) {
if (selectedHandCount > 0 || selectedTableCount > 0) {
btnDelete.textContent = `🗑️ Delete (${selectedHandCount + selectedTableCount})`;
} else {
btnDelete.textContent = '🗑️ Delete';
}
}
btnPickUp.textContent = diceOnTable.length > 0 ? `📦 Pick up (${diceOnTable.length})` : '📦 Pick up';
const handSum = diceInHand.reduce((acc, d) => acc + (d.result || 0), 0);
handTotalSum.textContent = handSum;
}
function renderDice() {
diceHand.innerHTML = '';
diceInHand.forEach(die => {
const dieEl = document.createElement('div');
dieEl.className = 'dice-item';
dieEl.dataset.id = die.id;
if (selected.has(die.id)) {
dieEl.classList.add('selected');
}
const icons = {
d4: '🔺', d6: '🎲', d8: '💎', d10: '🔟', d100: '💯', d12: '⬡', d20: '🔮'
};
dieEl.innerHTML = `
<div class="dice-icon">${icons[die.diceType] || '🎲'}</div>
<div class="dice-type">${die.diceType === 'd100' ? 'D%' : die.diceType.toUpperCase()}</div>
${die.result ? `<div class="dice-result">${die.result}</div>` : ''}
`;
dieEl.style.borderColor = die.color || playerColor;
diceHand.appendChild(dieEl);
});
if (diceInHand.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'empty-state';
emptyDiv.textContent = 'Tap above to add dice';
diceHand.appendChild(emptyDiv);
}
updateCounter();
}
function renderTable() {
tableDiceHand.innerHTML = '';
diceOnTable.forEach(die => {
const dieEl = document.createElement('div');
dieEl.className = 'dice-item';
dieEl.dataset.id = die.id;
if (selectedTable.has(die.id)) {
dieEl.classList.add('selected');
dieEl.style.borderColor = '#ffd700';
dieEl.style.background = 'rgba(255,215,0,0.2)';
} else {
dieEl.style.borderColor = 'rgba(255,215,0,0.5)';
dieEl.style.background = 'rgba(255,215,0,0.1)';
}
const icons = {
d4: '🔺', d6: '🎲', d8: '💎', d10: '🔟', d100: '💯', d12: '⬡', d20: '🔮'
};
dieEl.innerHTML = `
<div class="dice-icon">${icons[die.diceType] || '🎲'}</div>
<div class="dice-type">${die.diceType === 'd100' ? 'D%' : die.diceType.toUpperCase()}</div>
<div class="dice-result" style="font-size:12px; font-weight:bold;">${die.value}</div>
`;
tableDiceHand.appendChild(dieEl);
});
if (diceOnTable.length === 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'empty-state';
emptyDiv.textContent = 'No dice on the table';
tableDiceHand.appendChild(emptyDiv);
}
updateCounter();
}
function updateHandDie(dieId) {
const dice = diceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
if (dieEl.dataset.id === dieId) {
if (selected.has(dieId)) {
dieEl.classList.add('selected');
} else {
dieEl.classList.remove('selected');
}
return;
}
}
updateCounter();
}
function updateTableDie(dieId) {
const dice = tableDiceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
if (dieEl.dataset.id === dieId) {
if (selectedTable.has(dieId)) {
dieEl.classList.add('selected');
dieEl.style.borderColor = '#ffd700';
dieEl.style.background = 'rgba(255,215,0,0.2)';
dieEl.style.boxShadow = '0 0 10px rgba(255,215,0,0.5)';
} else {
dieEl.classList.remove('selected');
dieEl.style.borderColor = 'rgba(255,215,0,0.5)';
dieEl.style.background = 'rgba(255,215,0,0.1)';
dieEl.style.boxShadow = 'none';
}
return;
}
}
updateCounter();
}
function getHandDieAtPoint(clientX, clientY) {
const dice = diceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
const rect = dieEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
return dieEl;
}
}
return null;
}
function getTableDieAtPoint(clientX, clientY) {
const dice = tableDiceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
const rect = dieEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
return dieEl;
}
}
return null;
}
function selectHandDieUnderFinger(clientX, clientY) {
const dice = diceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
const rect = dieEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
const dieId = dieEl.dataset.id;
if (!selected.has(dieId)) {
selected.add(dieId);
updateHandDie(dieId);
}
return;
}
}
}
function selectTableDieUnderFinger(clientX, clientY) {
const dice = tableDiceHand.querySelectorAll('.dice-item');
for (const dieEl of dice) {
const rect = dieEl.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
const dieId = dieEl.dataset.id;
if (!selectedTable.has(dieId)) {
selectedTable.add(dieId);
updateTableDie(dieId);
}
return;
}
}
}
container.querySelectorAll('.dice-option').forEach(btn => {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
const diceType = btn.dataset.type;
const newDie = {
id: `${playerId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
diceType: diceType,
color: playerColor,
result: null
};
diceInHand.push(newDie);
selected.add(newDie.id);
renderDice();
}, {passive: false});
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const diceType = btn.dataset.type;
const newDie = {
id: `${playerId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
diceType: diceType,
color: playerColor,
result: null
};
diceInHand.push(newDie);
selected.add(newDie.id);
renderDice();
});
});
// === HAND - Touch ===
diceHand.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
isDragging = true;
hasMoved = false;
dragStartX = touch.clientX;
dragStartY = touch.clientY;
e.preventDefault();
}, {passive: false});
diceHand.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!isDragging) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - dragStartX);
const deltaY = Math.abs(touch.clientY - dragStartY);
if (deltaX > 5 || deltaY > 5) {
hasMoved = true;
}
selectHandDieUnderFinger(touch.clientX, touch.clientY);
}, {passive: false});
diceHand.addEventListener('touchend', (e) => {
if (!isDragging) return;
const touch = e.changedTouches[0];
if (!hasMoved) {
const dieEl = getHandDieAtPoint(touch.clientX, touch.clientY);
if (dieEl) {
const dieId = dieEl.dataset.id;
if (selected.has(dieId)) {
selected.delete(dieId);
} else {
selected.add(dieId);
}
updateHandDie(dieId);
}
}
isDragging = false;
hasMoved = false;
}, {passive: false});
// === TABLE - Touch ===
tableDiceHand.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
isDragging = true;
hasMoved = false;
dragStartX = touch.clientX;
dragStartY = touch.clientY;
e.preventDefault();
}, {passive: false});
tableDiceHand.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!isDragging) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - dragStartX);
const deltaY = Math.abs(touch.clientY - dragStartY);
if (deltaX > 5 || deltaY > 5) {
hasMoved = true;
}
selectTableDieUnderFinger(touch.clientX, touch.clientY);
}, {passive: false});
tableDiceHand.addEventListener('touchend', (e) => {
if (!isDragging) return;
const touch = e.changedTouches[0];
if (!hasMoved) {
const dieEl = getTableDieAtPoint(touch.clientX, touch.clientY);
if (dieEl) {
const dieId = dieEl.dataset.id;
if (selectedTable.has(dieId)) {
selectedTable.delete(dieId);
} else {
selectedTable.add(dieId);
}
updateTableDie(dieId);
}
}
isDragging = false;
hasMoved = false;
}, {passive: false});
// === Desktop - Hand ===
diceHand.addEventListener('mousedown', (e) => {
isDragging = true;
hasMoved = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
});
diceHand.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = Math.abs(e.clientX - dragStartX);
const deltaY = Math.abs(e.clientY - dragStartY);
if (deltaX > 5 || deltaY > 5) {
hasMoved = true;
}
selectHandDieUnderFinger(e.clientX, e.clientY);
});
diceHand.addEventListener('mouseup', (e) => {
if (!isDragging) return;
if (!hasMoved) {
const dieEl = getHandDieAtPoint(e.clientX, e.clientY);
if (dieEl) {
const dieId = dieEl.dataset.id;
if (selected.has(dieId)) {
selected.delete(dieId);
} else {
selected.add(dieId);
}
updateHandDie(dieId);
}
}
isDragging = false;
hasMoved = false;
});
// === Desktop - Table ===
tableDiceHand.addEventListener('mousedown', (e) => {
isDragging = true;
hasMoved = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
});
tableDiceHand.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = Math.abs(e.clientX - dragStartX);
const deltaY = Math.abs(e.clientY - dragStartY);
if (deltaX > 5 || deltaY > 5) {
hasMoved = true;
}
selectTableDieUnderFinger(e.clientX, e.clientY);
});
tableDiceHand.addEventListener('mouseup', (e) => {
if (!isDragging) return;
if (!hasMoved) {
const dieEl = getTableDieAtPoint(e.clientX, e.clientY);
if (dieEl) {
const dieId = dieEl.dataset.id;
if (selectedTable.has(dieId)) {
selectedTable.delete(dieId);
} else {
selectedTable.add(dieId);
}
updateTableDie(dieId);
}
}
isDragging = false;
hasMoved = false;
});
function rollSelectedDice() {
const selectedHandDice = diceInHand.filter(d => selected.has(d.id));
const selectedTableDice = diceOnTable.filter(d => selectedTable.has(d.id));
const unselectedTableDice = diceOnTable.filter(d => !selectedTable.has(d.id));
if (selectedHandDice.length === 0 && selectedTableDice.length === 0) {
if (diceOnTable.length > 0) {
const diceTypes = diceOnTable.map(d => d.diceType);
sdk.sendApp({
type: 'reroll_dice',
playerId: playerId,
diceTypes: diceTypes,
configuration: configuration
});
diceOnTable = [];
selectedTable.clear();
renderDice();
renderTable();
}
return;
}
if (selectedTableDice.length > 0) {
if (unselectedTableDice.length > 0) {
unselectedTableDice.forEach(die => {
diceInHand.push({
id: die.id,
diceType: die.diceType,
color: playerColor,
result: die.value
});
});
}
const diceTypes = selectedTableDice.map(d => d.diceType);
sdk.sendApp({
type: 'reroll_dice',
playerId: playerId,
diceTypes: diceTypes,
configuration: configuration
});
diceOnTable = [];
selectedTable.clear();
if (selectedHandDice.length > 0) {
sdk.sendApp({
type: 'roll_dice',
dice: selectedHandDice,
playerId: playerId,
configuration: configuration
});
diceInHand = diceInHand.filter(d => !selected.has(d.id));
selected.clear();
}
}
else {
sdk.sendApp({
type: 'roll_dice',
dice: selectedHandDice,
playerId: playerId,
configuration: configuration
});
diceInHand = diceInHand.filter(d => !selected.has(d.id));
selected.clear();
}
renderDice();
renderTable();
}
function deleteSelectedDice() {
const selectedHandDice = diceInHand.filter(d => selected.has(d.id));
const selectedTableDice = diceOnTable.filter(d => selectedTable.has(d.id));
if (selectedHandDice.length > 0 || selectedTableDice.length > 0) {
if (selectedHandDice.length > 0) {
diceInHand = diceInHand.filter(d => !selected.has(d.id));
selected.clear();
}
if (selectedTableDice.length > 0) {
const tableIds = selectedTableDice.map(d => d.id);
diceOnTable = diceOnTable.filter(d => !tableIds.includes(d.id));
selectedTable.clear();
sdk.sendApp({
type: 'remove_table_dice',
playerId: playerId,
diceIds: tableIds
});
}
confirmDelete = false;
btnDelete.textContent = '🗑️ Delete';
renderDice();
renderTable();
}
else {
if (!confirmDelete) {
confirmDelete = true;
btnDelete.textContent = '⚠️ CONFIRM';
if (confirmTimer) clearTimeout(confirmTimer);
confirmTimer = setTimeout(() => {
confirmDelete = false;
btnDelete.textContent = '🗑️ Delete';
}, 3000);
} else {
diceInHand = [];
selected.clear();
diceOnTable = [];
selectedTable.clear();
sdk.sendApp({
type: 'clear_full_table',
playerId: playerId
});
confirmDelete = false;
btnDelete.textContent = '🗑️ Delete';
if (confirmTimer) clearTimeout(confirmTimer);
renderDice();
renderTable();
}
}
}
function pickUpTableDice() {
if (selectedTable.size > 0) {
const selectedDice = diceOnTable.filter(d => selectedTable.has(d.id));
selectedDice.forEach(die => {
diceInHand.push({
id: die.id,
diceType: die.diceType,
color: playerColor,
result: die.value
});
});
const idsToPickUp = selectedDice.map(d => d.id);
diceOnTable = diceOnTable.filter(d => !idsToPickUp.includes(d.id));
selectedTable.clear();
sdk.sendApp({
type: 'remove_table_dice',
playerId: playerId,
diceIds: idsToPickUp
});
}
else if (diceOnTable.length > 0) {
diceOnTable.forEach(die => {
diceInHand.push({
id: die.id,
diceType: die.diceType,
color: playerColor,
result: die.value
});
});
diceOnTable = [];
selectedTable.clear();
sdk.sendApp({
type: 'clear_full_table',
playerId: playerId
});
}
renderDice();
renderTable();
}
btnRoll.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
rollSelectedDice();
}, {passive: false});
btnRoll.addEventListener('click', (e) => {
e.preventDefault();
rollSelectedDice();
});
btnDelete.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
deleteSelectedDice();
}, {passive: false});
btnDelete.addEventListener('click', (e) => {
e.preventDefault();
deleteSelectedDice();
});
btnPickUp.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
pickUpTableDice();
}, {passive: false});
btnPickUp.addEventListener('click', (e) => {
e.preventDefault();
pickUpTableDice();
});
return sdk.onApp((data) => {
switch(data.type) {
case 'table_results':
diceOnTable = data.results || [];
renderTable();
tableTotalSum.textContent = data.totalSum || 0;
break;
}
});
} .\apps\paint\client\mobile.js 35692 caracteres // Paint - mobile side. Same drawing/zoom/undo/admin panel as the original
// controller, but as a mounted module in the platform (room/admin/names
// are already managed by the SDK; here only lives the canvas).
export async function mount(container, sdk) {
// ============================================================
// BLOQUEO DE ZOOM DE PÁGINA
// ============================================================
let lastTouchEnd = 0;
const onTouchEnd = (e) => {
const now = Date.now();
if (now - lastTouchEnd <= 300) {
e.preventDefault();
}
lastTouchEnd = now;
};
const onGesture = (e) => e.preventDefault();
const onContextMenu = (e) => e.preventDefault();
document.addEventListener("touchend", onTouchEnd, { passive: false });
document.addEventListener("gesturestart", onGesture, { passive: false });
document.addEventListener("gesturechange", onGesture, { passive: false });
document.addEventListener("gestureend", onGesture, { passive: false });
document.addEventListener("contextmenu", onContextMenu);
function bindTap(el, handler) {
if (!el) return;
el.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
handler(e);
},
{ passive: false },
);
el.addEventListener("click", (e) => {
if ("ontouchstart" in window) return;
handler(e);
});
}
// Load CSS
const cssId = 'paint-mobile-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/paint/client/mobile/mobile.css`;
document.head.appendChild(link);
}
// Load HTML template
const response = await fetch(`/apps/paint/client/mobile/mobile.html`);
const html = await response.text();
container.innerHTML = html;
container.style.overflow = 'hidden';
const q = (sel) => container.querySelector(sel);
const canvas = q('#canvas');
const ctx = canvas.getContext('2d');
const wrapper = q('#canvasWrapper');
const colorPicker = q('#colorPicker');
const sizePicker = q('#sizePicker');
const sizeLabel = q('#sizeLabel');
const zoomLabel = q('#zoomLabel');
const modeLabel = q('#modeLabel');
const adminBtn = q('#adminBtn');
const adminPanelOverlay = q('#adminPanelOverlay');
const adminPanel = q('#adminPanel');
const trackpad = q('#trackpad');
const trackpadDot = q('#trackpadDot');
const fillBtn = q('#fillBtn');
const undoBtn = q('#undoBtn');
const timelineSlider = q('#timelineSlider');
const timelineInfo = q('#timelineInfo');
const playBtn = q('#playBtn');
const playersListPanel = q('#playersListPanel');
let CANVAS_WIDTH = 1920;
let CANVAS_HEIGHT = 1080;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let isDrawing = false;
let isPanning = false;
let lastX = 0, lastY = 0;
let panStartX = 0, panStartY = 0;
let color = '#FFFFFF';
let size = 5;
let mode = 'draw';
let fillMode = false;
let globalPoints = [];
let currentIndex = -1;
let totalEvents = 0;
let canvasOffsetX = 0;
let canvasOffsetY = 0;
let canvasScale = 1;
let currentActionId = null;
function playerId() { return sdk.state.playerId; }
function isAdmin() { return sdk.state.isAdmin; }
function applyLocalState(localState) {
if (!localState) return;
if (typeof localState.color === "string") {
color = localState.color;
if (colorPicker) colorPicker.value = color;
}
if (typeof localState.size === "number") {
size = localState.size;
if (sizePicker) sizePicker.value = size;
if (sizeLabel) sizeLabel.textContent = size;
}
if (typeof localState.mode === "string") {
setMode(localState.mode);
}
}
// ============================================================
// CHECKPOINTS
// ============================================================
let checkpoints = [];
const CHECKPOINT_INTERVAL = 120;
const MAX_CHECKPOINTS = 10;
function clearCheckpoints() { checkpoints = []; }
function saveCheckpoint(index) {
if (index < 0) return;
try {
const imageData = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
checkpoints.push({ index, imageData });
checkpoints.sort((a, b) => a.index - b.index);
if (checkpoints.length > MAX_CHECKPOINTS) checkpoints.shift();
} catch (e) { console.log('Could not create checkpoint:', e.message); }
}
function applyEvent(p) {
if (!p) return;
if (p.type === 'fill') floodFillLocal(p.x, p.y, p.color);
else drawPoint(p);
}
function renderUpTo(targetIndex) {
let base = null;
for (let i = checkpoints.length - 1; i >= 0; i--) {
if (checkpoints[i].index <= targetIndex) { base = checkpoints[i]; break; }
}
let startIndex;
if (base) {
ctx.putImageData(base.imageData, 0, 0);
startIndex = base.index + 1;
} else {
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
startIndex = 0;
}
let hadFill = false;
for (let i = startIndex; i <= targetIndex; i++) {
const p = globalPoints[i];
if (!p) continue;
applyEvent(p);
if (p.type === 'fill') hadFill = true;
}
currentIndex = targetIndex;
const sinceCheckpoint = targetIndex - (base ? base.index : -1);
if (targetIndex >= 0 && (hadFill || sinceCheckpoint >= CHECKPOINT_INTERVAL)) saveCheckpoint(targetIndex);
}
// ============================================================
// UNDO
// ============================================================
function requestUndo() { sdk.sendApp({ type: 'undo_own_action' }); }
// ============================================================
// CANVAS
// ============================================================
function initCanvas(w, h) {
CANVAS_WIDTH = w || 1920;
CANVAS_HEIGHT = h || 1080;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
updateTransform();
resizeCanvas();
}
function updateTransform() {
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
zoomLabel.textContent = Math.round(scale * 100) + '%';
}
function getCanvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const relX = (clientX - rect.left) / rect.width;
const relY = (clientY - rect.top) / rect.height;
return {
x: Math.max(0, Math.min(CANVAS_WIDTH, relX * CANVAS_WIDTH)),
y: Math.max(0, Math.min(CANVAS_HEIGHT, relY * CANVAS_HEIGHT))
};
}
function resizeCanvas() {
const rect = wrapper.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const aspect = CANVAS_WIDTH / CANVAS_HEIGHT;
let displayW, displayH;
if (w / h > aspect) { displayH = h; displayW = h * aspect; }
else { displayW = w; displayH = w / aspect; }
displayW = Math.floor(displayW);
displayH = Math.floor(displayH);
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const leftPos = Math.floor((w - displayW) / 2);
const topPos = Math.floor((h - displayH) / 2);
canvas.style.left = leftPos + 'px';
canvas.style.top = topPos + 'px';
}
// ============================================================
// REPORT VIEWPORT
// ============================================================
function reportViewport() {
const rect = wrapper.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
sdk.sendApp({
type: 'viewport',
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
// ============================================================
// ZOOM (local, mobile view only - doesn't affect TV)
// ============================================================
function applyCenteredZoom(newScale) {
const centerX = -offsetX + (CANVAS_WIDTH * scale) / 2;
const centerY = -offsetY + (CANVAS_HEIGHT * scale) / 2;
const scaleRatio = newScale / scale;
offsetX = -(centerX * scaleRatio) + (CANVAS_WIDTH * newScale) / 2;
offsetY = -(centerY * scaleRatio) + (CANVAS_HEIGHT * newScale) / 2;
scale = newScale;
updateTransform();
}
bindTap(q('#zoomOutBtn'), () => applyCenteredZoom(Math.max(0.2, scale / 1.2)));
bindTap(q('#zoomResetBtn'), () => applyCenteredZoom(1));
bindTap(q('#zoomInBtn'), () => applyCenteredZoom(Math.min(3, scale * 1.2)));
// Remote zoom/pan: moves the TV canvas (admin only).
function adminZoomIn() { if (!isAdmin()) return; sdk.sendApp({ type: 'move_canvas', offsetX: canvasOffsetX, offsetY: canvasOffsetY, scale: Math.min(3, canvasScale * 1.2) }); }
function adminZoomOut() { if (!isAdmin()) return; sdk.sendApp({ type: 'move_canvas', offsetX: canvasOffsetX, offsetY: canvasOffsetY, scale: Math.max(0.2, canvasScale / 1.2) }); }
function adminZoomReset() { if (!isAdmin()) return; sdk.sendApp({ type: 'move_canvas', offsetX: 0, offsetY: 0, scale: 1 }); }
function adminMoveTo(x, y, s) { if (!isAdmin()) return; sdk.sendApp({ type: 'move_canvas', offsetX: x, offsetY: y, scale: s }); }
bindTap(q('#adminZoomInBtn'), adminZoomIn);
bindTap(q('#adminZoomOutBtn'), adminZoomOut);
bindTap(q('#adminZoomResetBtn'), adminZoomReset);
bindTap(q('#adminCenterBtn'), () => adminMoveTo(0, 0, 1));
// ============================================================
// FILL
// ============================================================
function toggleFill() {
fillMode = !fillMode;
fillBtn.classList.toggle('active', fillMode);
modeLabel.textContent = fillMode ? 'Fill' : 'Drawing';
q('#drawModeBtn').classList.toggle('active', !fillMode);
}
bindTap(fillBtn, toggleFill);
function floodFill(x, y, fillColor) {
const imageData = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const data = imageData.data;
const width = CANVAS_WIDTH;
const height = CANVAS_HEIGHT;
const idx = (y * width + x) * 4;
const targetR = data[idx];
const targetG = data[idx + 1];
const targetB = data[idx + 2];
const fillR = parseInt(fillColor.slice(1, 3), 16);
const fillG = parseInt(fillColor.slice(3, 5), 16);
const fillB = parseInt(fillColor.slice(5, 7), 16);
if (targetR === fillR && targetG === fillG && targetB === fillB) return;
const TOLERANCE = 30;
function colorMatch(r, g, b) {
return Math.abs(r - targetR) <= TOLERANCE && Math.abs(g - targetG) <= TOLERANCE && Math.abs(b - targetB) <= TOLERANCE;
}
const queue = [{ x, y }];
const visited = new Set();
const key = (x, y) => `${x},${y}`;
while (queue.length > 0) {
const { x: cx, y: cy } = queue.shift();
const k = key(cx, cy);
if (visited.has(k)) continue;
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
const idx2 = (cy * width + cx) * 4;
if (!colorMatch(data[idx2], data[idx2 + 1], data[idx2 + 2])) continue;
visited.add(k);
data[idx2] = fillR; data[idx2 + 1] = fillG; data[idx2 + 2] = fillB; data[idx2 + 3] = 255;
queue.push({ x: cx + 1, y: cy }); queue.push({ x: cx - 1, y: cy });
queue.push({ x: cx, y: cy + 1 }); queue.push({ x: cx, y: cy - 1 });
}
ctx.putImageData(imageData, 0, 0);
const fillActionId = playerId() + '_fill_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
sdk.sendApp({ type: 'fill', x, y, color: fillColor, actionId: fillActionId });
}
function floodFillLocal(x, y, fillColor) {
const imageData = ctx.getImageData(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const data = imageData.data;
const width = CANVAS_WIDTH;
const height = CANVAS_HEIGHT;
const idx = (y * width + x) * 4;
const targetR = data[idx];
const targetG = data[idx + 1];
const targetB = data[idx + 2];
const fillR = parseInt(fillColor.slice(1, 3), 16);
const fillG = parseInt(fillColor.slice(3, 5), 16);
const fillB = parseInt(fillColor.slice(5, 7), 16);
if (targetR === fillR && targetG === fillG && targetB === fillB) return;
const TOLERANCE = 30;
function colorMatch(r, g, b) {
return Math.abs(r - targetR) <= TOLERANCE && Math.abs(g - targetG) <= TOLERANCE && Math.abs(b - targetB) <= TOLERANCE;
}
const queue = [{ x, y }];
const visited = new Set();
const key = (x, y) => `${x},${y}`;
while (queue.length > 0) {
const { x: cx, y: cy } = queue.shift();
const k = key(cx, cy);
if (visited.has(k)) continue;
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
const idx2 = (cy * width + cx) * 4;
if (!colorMatch(data[idx2], data[idx2 + 1], data[idx2 + 2])) continue;
visited.add(k);
data[idx2] = fillR; data[idx2 + 1] = fillG; data[idx2 + 2] = fillB; data[idx2 + 3] = 255;
queue.push({ x: cx + 1, y: cy }); queue.push({ x: cx - 1, y: cy });
queue.push({ x: cx, y: cy + 1 }); queue.push({ x: cx, y: cy - 1 });
}
ctx.putImageData(imageData, 0, 0);
}
// ============================================================
// DRAWING
// ============================================================
function drawLine(x1, y1, x2, y2, color, size) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color || '#000000';
ctx.lineWidth = size || 5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
function drawPoint(point) {
if (!point) return;
if (point.start || point.prevX === undefined || point.prevX === null) {
ctx.beginPath();
ctx.arc(point.x, point.y, (point.size || 5) / 2, 0, Math.PI * 2);
ctx.fillStyle = point.color || '#000000';
ctx.fill();
return;
}
drawLine(point.prevX, point.prevY, point.x, point.y, point.color, point.size);
}
// ============================================================
// SDK: canvas state
// ============================================================
let receivedInit = false;
const offApp = sdk.onApp((data) => {
if (data.type === 'paint_init') {
receivedInit = true;
globalPoints = data.events || [];
currentIndex = data.current || -1;
totalEvents = data.total || 0;
CANVAS_WIDTH = data.canvas.width || 1920;
CANVAS_HEIGHT = data.canvas.height || 1080;
canvasOffsetX = data.canvas.offsetX || 0;
canvasOffsetY = data.canvas.offsetY || 0;
canvasScale = data.canvas.scale || 1;
offsetX = 0; offsetY = 0; scale = 1;
initCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
clearCheckpoints();
renderUpTo(currentIndex);
updateTimeline(totalEvents, currentIndex);
}
if (data.type === 'canvas_resized') {
CANVAS_WIDTH = data.width;
CANVAS_HEIGHT = data.height;
offsetX = 0;
offsetY = 0;
scale = 1;
initCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
clearCheckpoints();
renderUpTo(currentIndex);
}
if (data.type === 'new_point') {
const point = data.point;
checkpoints = checkpoints.filter(cp => cp.index < data.current);
globalPoints.length = data.current;
globalPoints.push(point);
currentIndex = data.current;
totalEvents = data.total;
updateTimeline(totalEvents, currentIndex);
applyEvent(point);
}
if (data.type === 'history_index') {
totalEvents = data.total || 0;
renderUpTo(data.index);
updateTimeline(totalEvents, currentIndex);
}
if (data.type === 'clear') {
globalPoints = [];
totalEvents = 0;
clearCheckpoints();
renderUpTo(-1);
updateTimeline(totalEvents, currentIndex);
undoBtn.disabled = true;
}
if (data.type === 'history_replaced') {
globalPoints = data.events || [];
totalEvents = data.total || 0;
clearCheckpoints();
renderUpTo(data.current !== undefined ? data.current : globalPoints.length - 1);
updateTimeline(totalEvents, currentIndex);
}
if (data.type === 'undo_state') undoBtn.disabled = !data.canUndo;
if (data.type === 'backups_list') renderBackupsList(data.backups || []);
if (data.type === 'canvas_moved') {
canvasOffsetX = data.offsetX;
canvasOffsetY = data.offsetY;
canvasScale = data.scale;
}
if (data.type === 'error') alert('❌ ' + data.message);
});
const offLocalState = sdk.on("local_state", applyLocalState);
// ============================================================
// PLAYERS
// ============================================================
function renderPlayers() {
adminBtn.disabled = !isAdmin();
if (!isAdmin()) {
playersListPanel.innerHTML = '<span style="color:rgba(255,255,255,0.05);font-size:9px;">You are not admin</span>';
return;
}
playersListPanel.innerHTML = sdk.state.players.map(p => {
let badge = '<span class="badge">Player</span>';
if (p.id === sdk.state.adminId) badge = '<span class="badge admin-badge">👑 Admin</span>';
else if (p.role === 'tv') badge = '<span class="badge viewer-badge">📺 TV</span>';
return `<div class="player-item"><span class="name">${p.name}</span>${badge}</div>`;
}).join('');
}
const offPlayers1 = sdk.on('players_update', renderPlayers);
const offPlayers2 = sdk.on('player_joined', renderPlayers);
const offPlayers3 = sdk.on('player_left', renderPlayers);
const offPlayers4 = sdk.on('admin_changed', () => {
renderPlayers();
if (!isAdmin() && adminPanelOverlay.classList.contains('visible')) adminPanelOverlay.classList.remove('visible');
});
// ============================================================
// TIMELINE
// ============================================================
function updateTimeline(total, current) {
totalEvents = total || 0;
timelineSlider.max = Math.max(0, totalEvents - 1);
if (totalEvents === 0) {
timelineSlider.value = 0;
timelineSlider.disabled = true;
timelineInfo.textContent = '0 / 0';
} else {
timelineSlider.value = current >= 0 ? current : 0;
timelineSlider.disabled = false;
const displayCurrent = current >= 0 ? current + 1 : 1;
timelineInfo.textContent = `${displayCurrent} / ${totalEvents}`;
}
}
timelineSlider.addEventListener('input', function () {
const index = parseInt(this.value);
if (index !== currentIndex) sdk.sendApp({ type: 'set_history_index', index });
});
let playInterval = null;
bindTap(playBtn, () => {
if (playBtn.textContent === '▶️ Play') {
playBtn.textContent = '⏸️ Pause';
playInterval = setInterval(() => {
if (currentIndex < totalEvents - 1) {
sdk.sendApp({ type: 'set_history_index', index: currentIndex + 1 });
} else {
clearInterval(playInterval);
playBtn.textContent = '▶️ Play';
}
}, 200);
} else {
playBtn.textContent = '▶️ Play';
if (playInterval) { clearInterval(playInterval); playInterval = null; }
}
});
// ============================================================
// MODE
// ============================================================
function setMode(m) {
mode = m;
if (fillMode) { fillMode = false; fillBtn.classList.remove('active'); }
q('#drawModeBtn').classList.toggle('active', m === 'draw');
q('#navModeBtn').classList.toggle('active', m === 'nav');
modeLabel.textContent = m === 'draw' ? 'Drawing' : 'Navigation';
canvas.style.cursor = m === 'draw' ? 'crosshair' : 'grab';
}
function setModeAndSync(m) {
setMode(m);
sdk.sendLocalState({ mode: m });
}
bindTap(q('#drawModeBtn'), () => setModeAndSync('draw'));
bindTap(q('#navModeBtn'), () => setModeAndSync('nav'));
// ============================================================
// MOUSE / TOUCH EVENTS
// ============================================================
function getPointerPos(e) {
const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
const clientY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
return getCanvasCoords(clientX, clientY);
}
const MIN_POINT_DISTANCE = 3;
function onPointerDown(e) {
if (e.touches && e.touches.length > 1) { isDrawing = false; isPanning = false; return; }
e.preventDefault();
if (fillMode) {
const pos = getPointerPos(e);
floodFill(Math.round(pos.x), Math.round(pos.y), color);
return;
}
if (mode === 'nav') {
isPanning = true;
const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
const clientY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
panStartX = clientX - offsetX;
panStartY = clientY - offsetY;
canvas.style.cursor = 'grabbing';
return;
}
isDrawing = true;
currentActionId = playerId() + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
const pos = getPointerPos(e);
lastX = pos.x; lastY = pos.y;
const point = { type: 'point', x: lastX, y: lastY, color, size, start: true, userId: playerId(), actionId: currentActionId };
globalPoints.push(point);
currentIndex = globalPoints.length - 1;
totalEvents = globalPoints.length;
updateTimeline(totalEvents, currentIndex);
drawPoint(point);
sdk.sendApp({ type: 'draw_point', point });
}
function onPointerMove(e) {
if (e.touches && e.touches.length > 1) { isDrawing = false; return; }
e.preventDefault();
if (mode === 'nav' && isPanning) {
const clientX = e.clientX || (e.touches && e.touches[0].clientX) || 0;
const clientY = e.clientY || (e.touches && e.touches[0].clientY) || 0;
offsetX = clientX - panStartX;
offsetY = clientY - panStartY;
updateTransform();
return;
}
if (!isDrawing || mode !== 'draw') return;
const pos = getPointerPos(e);
const dx = pos.x - lastX;
const dy = pos.y - lastY;
if (Math.sqrt(dx * dx + dy * dy) < MIN_POINT_DISTANCE) return;
const point = { type: 'point', x: pos.x, y: pos.y, color, size, start: false, userId: playerId(), actionId: currentActionId, prevX: lastX, prevY: lastY };
globalPoints.push(point);
currentIndex = globalPoints.length - 1;
totalEvents = globalPoints.length;
updateTimeline(totalEvents, currentIndex);
drawPoint(point);
sdk.sendApp({ type: 'draw_point', point });
lastX = pos.x; lastY = pos.y;
}
function onPointerUp() {
if (isPanning) { isPanning = false; canvas.style.cursor = mode === 'draw' ? 'crosshair' : 'grab'; return; }
isDrawing = false;
}
canvas.addEventListener('mousedown', onPointerDown);
canvas.addEventListener('mousemove', onPointerMove);
canvas.addEventListener('mouseup', onPointerUp);
canvas.addEventListener('mouseleave', onPointerUp);
canvas.addEventListener('touchstart', onPointerDown, { passive: false });
canvas.addEventListener('touchmove', onPointerMove, { passive: false });
canvas.addEventListener('touchend', onPointerUp, { passive: false });
// ============================================================
// PINCH TO ZOOM (canvas only, not the page)
// ============================================================
let pinchStartDist = 0;
let pinchStartScale = 1;
let pinchFocalCanvasX = 0;
let pinchFocalCanvasY = 0;
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length !== 2) return;
const t0 = e.touches[0];
const t1 = e.touches[1];
const dx = t0.clientX - t1.clientX;
const dy = t0.clientY - t1.clientY;
pinchStartDist = Math.hypot(dx, dy);
pinchStartScale = scale;
const cx = (t0.clientX + t1.clientX) / 2;
const cy = (t0.clientY + t1.clientY) / 2;
const rect = canvas.getBoundingClientRect();
pinchFocalCanvasX = ((cx - rect.left) - offsetX) / scale;
pinchFocalCanvasY = ((cy - rect.top) - offsetY) / scale;
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (e.touches.length !== 2) return;
if (pinchStartDist === 0) return;
const t0 = e.touches[0];
const t1 = e.touches[1];
const dx = t0.clientX - t1.clientX;
const dy = t0.clientY - t1.clientY;
const dist = Math.hypot(dx, dy);
const ratio = dist / pinchStartDist;
const newScale = Math.min(3, Math.max(0.2, pinchStartScale * ratio));
offsetX = offsetX + pinchFocalCanvasX * (scale - newScale);
offsetY = offsetY + pinchFocalCanvasY * (scale - newScale);
scale = newScale;
updateTransform();
}, { passive: true });
canvas.addEventListener('touchend', (e) => {
if (e.touches.length < 2) {
pinchStartDist = 0;
}
}, { passive: true });
// ============================================================
// TRACKPAD (moves the TV canvas)
// ============================================================
let isTrackpadDragging = false;
let lastTrackpadX = 0, lastTrackpadY = 0;
const trackpadSensitivity = 2.5;
function updateTrackpadDot(x, y) {
const rect = trackpad.getBoundingClientRect();
const relX = Math.max(0, Math.min(1, (x - rect.left) / rect.width));
const relY = Math.max(0, Math.min(1, (y - rect.top) / rect.height));
trackpadDot.style.left = (relX * 100) + '%';
trackpadDot.style.top = (relY * 100) + '%';
trackpadDot.style.transform = 'translate(-50%, -50%)';
}
function trackpadDrag(clientX, clientY) {
const deltaX = (clientX - lastTrackpadX) * trackpadSensitivity;
const deltaY = (clientY - lastTrackpadY) * trackpadSensitivity;
sdk.sendApp({ type: 'move_canvas', offsetX: canvasOffsetX + deltaX, offsetY: canvasOffsetY + deltaY, scale: canvasScale });
lastTrackpadX = clientX; lastTrackpadY = clientY;
updateTrackpadDot(clientX, clientY);
}
function trackpadReset() {
isTrackpadDragging = false;
trackpadDot.style.left = '50%';
trackpadDot.style.top = '50%';
trackpadDot.style.transform = 'translate(-50%, -50%)';
}
trackpad.addEventListener('touchstart', (e) => {
e.preventDefault();
if (!isAdmin()) return;
isTrackpadDragging = true;
lastTrackpadX = e.touches[0].clientX; lastTrackpadY = e.touches[0].clientY;
updateTrackpadDot(lastTrackpadX, lastTrackpadY);
}, { passive: false });
trackpad.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!isTrackpadDragging || !isAdmin()) return;
trackpadDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
trackpad.addEventListener('touchend', trackpadReset, { passive: true });
trackpad.addEventListener('mousedown', (e) => {
if (!isAdmin()) return;
isTrackpadDragging = true;
lastTrackpadX = e.clientX; lastTrackpadY = e.clientY;
updateTrackpadDot(e.clientX, e.clientY);
});
trackpad.addEventListener('mousemove', (e) => {
if (!isTrackpadDragging || !isAdmin()) return;
trackpadDrag(e.clientX, e.clientY);
});
trackpad.addEventListener('mouseup', trackpadReset);
trackpad.addEventListener('mouseleave', trackpadReset);
let adminPanelTouchY = null;
let adminPanelScrollStart = 0;
function isAdminPanelScrollExempt(target) {
return !!(target.closest('.trackpad') || target.closest('input'));
}
adminPanel.addEventListener('touchstart', (e) => {
if (isAdminPanelScrollExempt(e.target)) return;
adminPanelTouchY = e.touches[0].clientY;
adminPanelScrollStart = adminPanel.scrollTop;
}, { passive: true });
adminPanel.addEventListener('touchmove', (e) => {
if (adminPanelTouchY === null || isAdminPanelScrollExempt(e.target)) return;
adminPanel.scrollTop = adminPanelScrollStart + (adminPanelTouchY - e.touches[0].clientY);
e.preventDefault();
}, { passive: false });
adminPanel.addEventListener('touchend', () => { adminPanelTouchY = null; }, { passive: true });
// ============================================================
// ADMIN PANEL
// ============================================================
function toggleAdminPanel() {
if (!isAdmin()) return;
adminPanelOverlay.classList.toggle('visible');
if (adminPanelOverlay.classList.contains('visible')) {
switchAdminTab('canvas');
refreshBackups();
renderPlayers();
}
}
bindTap(adminBtn, toggleAdminPanel);
bindTap(q('#closePanelBtn'), toggleAdminPanel);
adminPanelOverlay.addEventListener('click', (e) => { if (e.target === adminPanelOverlay) toggleAdminPanel(); });
function switchAdminTab(tab) {
q('#tabBtnCanvas').classList.toggle('active', tab === 'canvas');
q('#tabBtnRoom').classList.toggle('active', tab === 'room');
q('#tabCanvas').classList.toggle('active', tab === 'canvas');
q('#tabRoom').classList.toggle('active', tab === 'room');
}
bindTap(q('#tabBtnCanvas'), () => switchAdminTab('canvas'));
bindTap(q('#tabBtnRoom'), () => switchAdminTab('room'));
// ============================================================
// CANVAS SIZE MODE
// ============================================================
bindTap(q('#sizeAutoBtn'), () => {
if (!isAdmin()) return;
sdk.sendApp({ type: 'set_canvas_size', mode: 'auto' });
});
bindTap(q('#sizeTvBtn'), () => {
if (!isAdmin()) return;
sdk.sendApp({ type: 'set_canvas_size', mode: 'tv' });
});
bindTap(q('#sizeAdminBtn'), () => {
if (!isAdmin()) return;
const rect = wrapper.getBoundingClientRect();
sdk.sendApp({
type: 'set_canvas_size',
mode: 'admin',
width: Math.round(rect.width),
height: Math.round(rect.height),
});
});
// ============================================================
// BACKUPS
// ============================================================
function refreshBackups() { if (isAdmin()) sdk.sendApp({ type: 'list_backups' }); }
bindTap(q('#refreshBackupsBtn'), refreshBackups);
function renderBackupsList(backups) {
const panel = q('#backupsListPanel');
if (!panel) return;
if (backups.length === 0) {
panel.innerHTML = '<span style="color:rgba(255,255,255,0.05);font-size:9px;">No saved copies</span>';
return;
}
panel.innerHTML = backups.map(b => {
const label = new Date(b.created_at).toLocaleTimeString();
return `<div class="player-item">` +
`<span class="name">${label} · point ${b.history_index + 1}/${b.total}</span>` +
`<span class="badge restore-badge" style="cursor:pointer;" data-id="${b.id}">♻️ Restore</span>` +
`</div>`;
}).join('');
panel.querySelectorAll('.restore-badge').forEach(el => {
bindTap(el, () => {
if (!confirm('Restore this backup? If the current drawing is not already saved, it will be kept as a recoverable copy.')) return;
sdk.sendApp({ type: 'restore_backup', id: parseInt(el.dataset.id, 10) });
});
});
}
// ============================================================
// CONTROLS
// ============================================================
colorPicker.addEventListener('input', (e) => {
color = e.target.value;
sdk.sendLocalState({ color });
});
sizePicker.addEventListener('input', (e) => {
size = parseInt(e.target.value);
sizeLabel.textContent = size;
sdk.sendLocalState({ size });
});
bindTap(q('#clearBtn'), () => {
if (!isAdmin()) { alert('Only admin can clear'); return; }
if (!confirm('Clear the entire drawing?')) return;
sdk.sendApp({ type: 'clear' });
globalPoints = [];
totalEvents = 0;
updateTimeline(totalEvents, currentIndex);
clearCheckpoints();
renderUpTo(-1);
undoBtn.disabled = true;
});
bindTap(undoBtn, requestUndo);
// ============================================================
// INIT
// ============================================================
applyLocalState(sdk.state.localState);
const onResize = () => {
resizeCanvas();
reportViewport();
};
window.addEventListener('resize', onResize);
if (!receivedInit) {
initCanvas(1920, 1080);
resizeCanvas();
}
setMode('draw');
renderPlayers();
setTimeout(reportViewport, 100);
return () => {
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('gesturestart', onGesture);
document.removeEventListener('gesturechange', onGesture);
document.removeEventListener('gestureend', onGesture);
document.removeEventListener('contextmenu', onContextMenu);
window.removeEventListener('resize', onResize);
offApp();
offLocalState();
offPlayers1(); offPlayers2(); offPlayers3(); offPlayers4();
if (playInterval) clearInterval(playInterval);
};
} .\apps\maps\client\mobile.js 36675 caracteres // Mobile side. Route management with sliding tabs
export async function mount(container, sdk) {
// Load CSS
const cssId = 'maps-mobile-style';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = `/apps/maps/client/mobile/mobile.css`;
document.head.appendChild(link);
}
// Load HTML template
const response = await fetch(`/apps/maps/client/mobile/mobile.html`);
const html = await response.text();
container.innerHTML = html;
container.style.overflow = 'hidden';
let myRoutes = [];
let activeRouteId = null;
let savedRoutes = [];
let routeColors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#a78bfa', '#ffd700', '#ff9ff3'];
let colorIndex = 0;
let confirmClear = false;
let confirmClearTimer = null;
let debounceTimer = null;
let draggingResult = null;
let movementSensitivity = 15;
let pinActive = false;
let updateInterval = null;
// Rotación sincronizada con el servidor. Se usa para calcular
// el ángulo del touchpad sin depender del valor del slider
// (que se desincronizaba al recargar la página).
let serverRotation = 0;
// Registry of active "hold to delete" timers
let activeLongPressIntervals = new Set();
const tabsTrack = container.querySelector('#tabsTrack');
const routeContent = container.querySelector('#rutaContent');
const searchInput = container.querySelector('#searchInput');
const btnClearSearch = container.querySelector('#btnClearSearch');
const searchResults = container.querySelector('#searchResults');
const routeCount = container.querySelector('#rutaCount');
const settingsBtn = container.querySelector('#settingsBtn');
const settingsOverlay = container.querySelector('#settingsOverlay');
const closeSettingsBtn = container.querySelector('#closeSettingsBtn');
const trackpad = container.querySelector('#trackpad');
const trackpadDot = container.querySelector('#trackpadDot');
const sensitivitySlider = container.querySelector('#sensibilidadSlider');
const sensitivityValue = container.querySelector('#sensibilidadValue');
const savedRoutesList = container.querySelector('#savedRutasList');
const btnNewRoute = container.querySelector('#btnNuevaRuta');
const btnTogglePin = container.querySelector('#btnToggleChincheta');
const panelCoordLat = container.querySelector('#panelCoordLat');
const panelCoordLng = container.querySelector('#panelCoordLng');
const btnAddCoords = container.querySelector('#btnAddCoords');
const rotationSlider = container.querySelector('#rotacionSlider');
const rotationValue = container.querySelector('#rotacionValue');
const playerId = sdk.state.playerId;
function highlightPointOnTV(routeId, pointIndex, isHighlighted) {
sdk.sendApp({
type: 'highlight_point',
playerId: playerId,
routeId: routeId,
pointIndex: pointIndex,
highlighted: isHighlighted
});
}
btnTogglePin.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
pinActive = !pinActive;
if (pinActive) {
btnTogglePin.classList.add('active');
sdk.sendApp({ type: 'toggle_pin', playerId: playerId, visible: true });
sdk.sendApp({ type: 'request_map_center', playerId: playerId });
updateInterval = setInterval(() => { sdk.sendApp({ type: 'request_map_center', playerId: playerId }); }, 300);
} else {
btnTogglePin.classList.remove('active');
sdk.sendApp({ type: 'toggle_pin', playerId: playerId, visible: false });
if (updateInterval) { clearInterval(updateInterval); updateInterval = null; }
}
});
btnAddCoords.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const lat = parseFloat(panelCoordLat.value);
const lng = parseFloat(panelCoordLng.value);
if (isNaN(lat) || isNaN(lng)) return;
const currentRoute = myRoutes.find(r => r.id === activeRouteId);
if (currentRoute) {
currentRoute.points.push({ lat, lng, invertType: false });
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: currentRoute.id, points: currentRoute.points });
renderActiveRoute();
updateTabs();
}
});
rotationSlider.addEventListener('input', (e) => {
const rotation = parseFloat(e.target.value);
// Actualizamos también la copia local para que el touchpad
// la use inmediatamente, sin esperar a que el servidor
// retransmita el mensaje.
serverRotation = rotation;
rotationValue.textContent = `${rotation}°`;
sdk.sendApp({ type: 'rotate_map', playerId: playerId, rotation: rotation });
});
btnClearSearch.addEventListener('click', (e) => {
e.preventDefault();
searchInput.value = '';
searchResults.innerHTML = '';
btnClearSearch.style.display = 'none';
});
container.querySelector('#tabBtnMapa').addEventListener('click', (e) => {
e.preventDefault();
container.querySelector('#tabBtnMapa').classList.add('active');
container.querySelector('#tabBtnGuardadas').classList.remove('active');
container.querySelector('#tabMapa').classList.add('active');
container.querySelector('#tabGuardadas').classList.remove('active');
});
container.querySelector('#tabBtnGuardadas').addEventListener('click', (e) => {
e.preventDefault();
container.querySelector('#tabBtnGuardadas').classList.add('active');
container.querySelector('#tabBtnMapa').classList.remove('active');
container.querySelector('#tabGuardadas').classList.add('active');
container.querySelector('#tabMapa').classList.remove('active');
sdk.sendApp({ type: 'request_saved_routes', playerId: playerId });
});
sensitivitySlider.addEventListener('input', (e) => {
movementSensitivity = parseFloat(e.target.value);
sensitivityValue.textContent = movementSensitivity;
});
settingsBtn.addEventListener('click', (e) => {
e.preventDefault();
settingsOverlay.classList.add('visible');
sdk.sendApp({ type: 'request_saved_routes', playerId: playerId });
});
closeSettingsBtn.addEventListener('click', (e) => {
e.preventDefault();
settingsOverlay.classList.remove('visible');
});
let isTrackpadDragging = false;
let trackpadLastX = 0;
let trackpadLastY = 0;
trackpad.addEventListener('touchstart', (e) => {
e.preventDefault();
isTrackpadDragging = true;
const touch = e.touches[0];
trackpadLastX = touch.clientX;
trackpadLastY = touch.clientY;
trackpadDot.style.display = 'block';
}, {passive: false});
trackpad.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!isTrackpadDragging) return;
const touch = e.touches[0];
const deltaX = touch.clientX - trackpadLastX;
const deltaY = touch.clientY - trackpadLastY;
// Usamos la rotación del SERVIDOR, no la del slider.
// Así el touchpad funciona aunque el slider aún no se
// haya sincronizado tras una recarga.
const currentRotation = serverRotation;
const radians = currentRotation * (Math.PI / 180);
const cos = Math.cos(-radians);
const sin = Math.sin(-radians);
const rotatedDeltaX = deltaX * cos - deltaY * sin;
const rotatedDeltaY = deltaX * sin + deltaY * cos;
sdk.sendApp({
type: 'move_map',
deltaX: rotatedDeltaX * movementSensitivity,
deltaY: rotatedDeltaY * movementSensitivity
});
trackpadLastX = touch.clientX;
trackpadLastY = touch.clientY;
const trackpadRect = trackpad.getBoundingClientRect();
const percentX = ((touch.clientX - trackpadRect.left) / trackpadRect.width) * 100;
const percentY = ((touch.clientY - trackpadRect.top) / trackpadRect.height) * 100;
trackpadDot.style.left = `${Math.max(5, Math.min(95, percentX))}%`;
trackpadDot.style.top = `${Math.max(5, Math.min(95, percentY))}%`;
}, {passive: false});
trackpad.addEventListener('touchend', () => {
isTrackpadDragging = false;
trackpadDot.style.display = 'none';
});
container.querySelector('#zoomInBtn').addEventListener('click', (e) => {
e.preventDefault();
sdk.sendApp({ type: 'zoom_map', deltaZoom: 1 });
});
container.querySelector('#zoomOutBtn').addEventListener('click', (e) => {
e.preventDefault();
sdk.sendApp({ type: 'zoom_map', deltaZoom: -1 });
});
container.querySelector('#zoomResetBtn').addEventListener('click', (e) => {
e.preventDefault();
sdk.sendApp({ type: 'reset_map' });
});
function clearAllRoutes() {
if (!confirmClear) {
confirmClear = true;
container.querySelector('#btnLimpiarPanel').textContent = '⚠️ Confirm?';
if (confirmClearTimer) clearTimeout(confirmClearTimer);
confirmClearTimer = setTimeout(() => {
confirmClear = false;
container.querySelector('#btnLimpiarPanel').textContent = '🗑️ Clear all';
}, 3000);
} else {
sdk.sendApp({ type: 'clear_all_routes', playerId: playerId });
myRoutes = [];
activeRouteId = null;
confirmClear = false;
container.querySelector('#btnLimpiarPanel').textContent = '🗑️ Clear all';
if (confirmClearTimer) clearTimeout(confirmClearTimer);
updateTabs();
settingsOverlay.classList.remove('visible');
}
}
container.querySelector('#btnLimpiarPanel').addEventListener('click', (e) => {
e.preventDefault();
clearAllRoutes();
});
async function searchPlaces(query) {
if (!query || query.trim().length < 3) return;
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5`, {
headers: { 'Accept': 'application/json' }
});
const results = await response.json();
searchResults.innerHTML = '';
if (results.length === 0) {
searchResults.innerHTML = '<div style="text-align:center; color:rgba(255,255,255,0.3); padding:10px; font-size:12px;">No results</div>';
return;
}
results.forEach(result => {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item';
const name = result.display_name.split(',')[0];
resultEl.innerHTML = `<span class="search-result-name">📍 ${name}</span>`;
resultEl.dataset.lat = result.lat;
resultEl.dataset.lng = result.lon;
resultEl.dataset.name = name;
searchResults.appendChild(resultEl);
});
} catch (error) {
searchResults.innerHTML = '<div style="text-align:center; color:#f44336; padding:10px; font-size:12px;">Search error</div>';
}
}
searchInput.addEventListener('input', (e) => {
const query = searchInput.value;
if (query.length > 0) {
btnClearSearch.style.display = 'block';
} else {
btnClearSearch.style.display = 'none';
}
if (debounceTimer) clearTimeout(debounceTimer);
if (query.trim().length >= 3) {
debounceTimer = setTimeout(() => { searchPlaces(query); }, 500);
} else {
searchResults.innerHTML = '';
}
});
function updateTabs() {
routeCount.textContent = `${myRoutes.length} routes`;
if (myRoutes.length === 0) {
tabsTrack.innerHTML = '';
routeContent.innerHTML = `<div style="text-align:center; color:rgba(255,255,255,0.3); padding:20px; font-style:italic; font-size:14px;">You have no routes. Create one!</div>`;
return;
}
if (!activeRouteId || !myRoutes.find(r => r.id === activeRouteId)) {
activeRouteId = myRoutes[0].id;
}
tabsTrack.innerHTML = '';
myRoutes.forEach(route => {
const tabEl = document.createElement('div');
tabEl.className = `tab-item ${route.id === activeRouteId ? 'active' : 'inactive'}`;
tabEl.dataset.routeId = route.id;
tabEl.textContent = route.name;
tabEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
activeRouteId = route.id;
updateTabs();
});
tabsTrack.appendChild(tabEl);
});
setTimeout(() => {
const activeTab = tabsTrack.querySelector('.tab-item.active');
if (activeTab) {
const containerWidth = container.querySelector('.tabs-container').clientWidth;
const tabWidth = activeTab.offsetWidth;
const tabOffset = activeTab.offsetLeft;
const scrollTo = tabOffset - (containerWidth / 2) + (tabWidth / 2);
tabsTrack.style.transform = `translateX(${-scrollTo}px)`;
}
}, 50);
renderActiveRoute();
}
function renderActiveRoute() {
const route = myRoutes.find(r => r.id === activeRouteId);
if (!route) { routeContent.innerHTML = ''; return; }
const routeColor = route.color || '#a78bfa';
// Save scroll position of points list
const currentPointsList = routeContent.querySelector('.puntos-list');
const scrollPosition = currentPointsList ? currentPointsList.scrollTop : 0;
routeContent.innerHTML = `
<div class="ruta-item" data-route-id="${route.id}">
<div class="ruta-header">
<div style="display:flex; align-items:center; gap:6px; flex:1;">
<span style="width:12px; height:12px; border-radius:50%; background:${routeColor}; flex-shrink:0;"></span>
<input type="text" class="ruta-nombre-input" value="${route.name}" data-id="${route.id}">
<span style="font-size:10px; color:rgba(255,255,255,0.3);">(${route.points.length} pts)</span>
</div>
<div class="ruta-actions">
<button class="btn-accion btn-guardar-ruta" data-action="save" data-id="${route.id}">💾</button>
<button class="btn-accion btn-visible-toggle" data-action="toggle" data-id="${route.id}">${route.visible ? '👁️' : '🚫'}</button>
<button class="btn-accion btn-eliminar-ruta" data-action="delete" data-id="${route.id}">✕</button>
</div>
</div>
<div class="controls-row">
<button class="btn-accion btn-tipo-defecto ${route.byRoad ? 'active' : ''}" data-action="toggle-type" data-id="${route.id}">🛣️</button>
<button class="btn-accion btn-zoom-ruta" data-action="zoom" data-id="${route.id}">🔍</button>
<button class="btn-accion btn-toggle-marcadores ${route.showMarkers !== false ? '' : 'hidden'}" data-action="toggle-markers" data-id="${route.id}">📍</button>
<div class="color-picker">
<button class="btn-accion btn-toggle-lineas ${route.showLines !== false ? '' : 'hidden'}" data-action="toggle-lines" data-id="${route.id}">📏</button>
<span>Color:</span>
<input type="color" value="${routeColor}" data-id="${route.id}" class="ruta-color">
</div>
</div>
<div class="puntos-list" data-route-id="${route.id}">
${route.points.map((point, index) => `
<div class="punto-item" data-route-id="${route.id}" data-point-index="${index}">
<button class="btn-eliminar-punto" data-action="delete-point" data-route-id="${route.id}" data-point-index="${index}">✕</button>
<span style="font-size:10px; color:rgba(255,255,255,0.3); flex-shrink:0;">${index + 1}.</span>
<span class="punto-coords">${point.name || `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`}</span>
<span class="punto-drag-handle">≡</span>
<button class="btn-segmento ${point.invertType ? 'active' : ''}" data-action="toggle-segment" data-route-id="${route.id}" data-point-index="${index}">🔄</button>
</div>
`).join('')}
</div>
</div>
`;
// Restore scroll position
const newPointsList = routeContent.querySelector('.puntos-list');
if (newPointsList) {
newPointsList.scrollTop = scrollPosition;
}
routeContent.querySelectorAll('.btn-eliminar-punto').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const routeId = btn.dataset.routeId;
const pointIndex = parseInt(btn.dataset.pointIndex);
const currentRoute = myRoutes.find(r => r.id === routeId);
if (currentRoute) {
currentRoute.points.splice(pointIndex, 1);
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: routeId, points: currentRoute.points });
renderActiveRoute();
}
});
});
routeContent.querySelectorAll('.btn-segmento').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const routeId = btn.dataset.routeId;
const pointIndex = parseInt(btn.dataset.pointIndex);
const currentRoute = myRoutes.find(r => r.id === routeId);
if (currentRoute && currentRoute.points[pointIndex]) {
const point = currentRoute.points[pointIndex];
point.invertType = !point.invertType;
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: routeId, points: currentRoute.points });
renderActiveRoute();
}
});
});
routeContent.querySelectorAll('.btn-accion').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const routeId = btn.dataset.id;
const action = btn.dataset.action;
const currentRoute = myRoutes.find(r => r.id === routeId);
if (!currentRoute) return;
if (action === 'toggle') {
sdk.sendApp({ type: 'toggle_route_visibility', playerId: playerId, routeId: routeId });
currentRoute.visible = !currentRoute.visible;
renderActiveRoute();
updateTabs();
} else if (action === 'delete') {
sdk.sendApp({ type: 'delete_route', playerId: playerId, routeId: routeId });
myRoutes = myRoutes.filter(r => r.id !== routeId);
if (activeRouteId === routeId) activeRouteId = null;
updateTabs();
} else if (action === 'save') {
sdk.sendApp({ type: 'save_route', playerId: playerId, routeId: routeId, name: currentRoute.name, points: currentRoute.points, color: currentRoute.color, byRoad: currentRoute.byRoad, visible: currentRoute.visible, showMarkers: currentRoute.showMarkers !== false, showLines: currentRoute.showLines !== false });
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '💾'; }, 2000);
} else if (action === 'zoom') {
sdk.sendApp({ type: 'zoom_to_route', playerId: playerId, routeId: routeId });
} else if (action === 'toggle-markers') {
currentRoute.showMarkers = currentRoute.showMarkers === false ? true : false;
sdk.sendApp({ type: 'toggle_route_markers', playerId: playerId, routeId: routeId, show: currentRoute.showMarkers });
renderActiveRoute();
} else if (action === 'toggle-type') {
currentRoute.byRoad = !currentRoute.byRoad;
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: routeId, byRoad: currentRoute.byRoad });
renderActiveRoute();
} else if (action === 'toggle-lines') {
currentRoute.showLines = currentRoute.showLines === false ? true : false;
sdk.sendApp({ type: 'toggle_route_lines', playerId: playerId, routeId: routeId, show: currentRoute.showLines });
renderActiveRoute();
}
});
});
routeContent.querySelectorAll('.ruta-nombre-input').forEach(input => {
input.addEventListener('change', (e) => {
const routeId = input.dataset.id;
const newName = input.value;
const currentRoute = myRoutes.find(r => r.id === routeId);
if (currentRoute && newName.trim() !== '') {
currentRoute.name = newName.trim();
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: routeId, name: currentRoute.name });
updateTabs();
} else {
input.value = currentRoute.name;
}
});
});
routeContent.querySelectorAll('.ruta-color').forEach(colorInput => {
colorInput.addEventListener('change', (e) => {
const routeId = colorInput.dataset.id;
const newColor = colorInput.value;
const currentRoute = myRoutes.find(r => r.id === routeId);
if (currentRoute) {
currentRoute.color = newColor;
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: routeId, color: newColor });
}
});
});
// Add touch handlers for point highlighting
routeContent.querySelectorAll('.punto-item').forEach(pointItem => {
const routeId = pointItem.dataset.routeId;
const pointIndex = parseInt(pointItem.dataset.pointIndex);
pointItem.addEventListener('touchstart', (e) => {
if (e.target.closest('.punto-drag-handle') ||
e.target.closest('.btn-eliminar-punto') ||
e.target.closest('.btn-segmento')) {
return;
}
highlightPointOnTV(routeId, pointIndex, true);
}, {passive: true});
pointItem.addEventListener('touchend', () => {
highlightPointOnTV(routeId, pointIndex, false);
}, {passive: true});
pointItem.addEventListener('touchcancel', () => {
highlightPointOnTV(routeId, pointIndex, false);
}, {passive: true});
});
routeContent.querySelectorAll('.punto-drag-handle').forEach(handle => {
handle.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
const pointItem = handle.closest('.punto-item');
if (pointItem) {
const draggedPoint = pointItem;
const draggedPointIndex = parseInt(pointItem.dataset.pointIndex);
pointItem.classList.add('dragging');
const handleTouchMove = (e2) => {
e2.preventDefault();
const touch = e2.touches[0];
const pointsList = routeContent.querySelector('.puntos-list');
const points = pointsList.querySelectorAll('.punto-item');
points.forEach(point => {
if (point !== draggedPoint) {
const rect = point.getBoundingClientRect();
if (touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
point.classList.add('drag-over');
} else {
point.classList.remove('drag-over');
}
}
});
};
const handleTouchEnd = (e2) => {
e2.preventDefault();
const touch = e2.changedTouches[0];
const pointsList = routeContent.querySelector('.puntos-list');
const points = pointsList.querySelectorAll('.punto-item');
let destinationIndex = draggedPointIndex;
points.forEach((point, index) => {
point.classList.remove('drag-over');
if (point !== draggedPoint) {
const rect = point.getBoundingClientRect();
if (touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
destinationIndex = index;
}
}
});
draggedPoint.classList.remove('dragging');
const currentRoute = myRoutes.find(r => r.id === activeRouteId);
if (currentRoute && destinationIndex !== draggedPointIndex) {
const movedPoint = currentRoute.points.splice(draggedPointIndex, 1)[0];
currentRoute.points.splice(destinationIndex, 0, movedPoint);
sdk.sendApp({
type: 'update_route',
playerId: playerId,
routeId: currentRoute.id,
points: currentRoute.points
});
renderActiveRoute();
}
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, {passive: false});
document.addEventListener('touchend', handleTouchEnd, {passive: false});
}
}, {passive: false});
});
}
searchResults.addEventListener('touchstart', (e) => {
const resultEl = e.target.closest('.search-result-item');
if (resultEl) {
draggingResult = resultEl;
resultEl.classList.add('dragging');
}
}, {passive: true});
searchResults.addEventListener('touchmove', (e) => {
if (draggingResult) {
e.preventDefault();
const touch = e.touches[0];
const routeEl = routeContent.querySelector('.ruta-item');
if (routeEl) {
const rect = routeEl.getBoundingClientRect();
if (touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
routeEl.classList.add('drag-over');
} else {
routeEl.classList.remove('drag-over');
}
}
}
}, {passive: false});
searchResults.addEventListener('touchend', (e) => {
if (draggingResult) {
draggingResult.classList.remove('dragging');
const touch = e.changedTouches[0];
const routeEl = routeContent.querySelector('.ruta-item');
if (routeEl) {
routeEl.classList.remove('drag-over');
const rect = routeEl.getBoundingClientRect();
if (touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
const route = myRoutes.find(r => r.id === activeRouteId);
if (route && draggingResult) {
const lat = parseFloat(draggingResult.dataset.lat);
const lng = parseFloat(draggingResult.dataset.lng);
const name = draggingResult.dataset.name;
route.points.push({ lat, lng, name, invertType: false });
sdk.sendApp({ type: 'update_route', playerId: playerId, routeId: route.id, points: route.points });
renderActiveRoute();
updateTabs();
}
}
}
draggingResult = null;
}
}, {passive: true});
btnNewRoute.addEventListener('click', (e) => {
e.preventDefault();
const color = routeColors[colorIndex % routeColors.length];
colorIndex++;
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const newRoute = { id: tempId, name: `Route ${myRoutes.length + 1}`, color: color, points: [], visible: true, byRoad: true, showMarkers: true };
sdk.sendApp({ type: 'create_route', playerId: playerId, tempId: tempId, name: newRoute.name, color: newRoute.color, points: newRoute.points, byRoad: newRoute.byRoad });
myRoutes.push(newRoute);
activeRouteId = newRoute.id;
updateTabs();
});
function renderSavedRoutes() {
activeLongPressIntervals.forEach(id => clearInterval(id));
activeLongPressIntervals.clear();
savedRoutesList.innerHTML = '';
if (savedRoutes.length === 0) {
savedRoutesList.innerHTML = '<div style="text-align:center; color:rgba(255,255,255,0.3); padding:10px; font-size:12px;">No saved routes</div>';
return;
}
savedRoutes.forEach(route => {
const itemEl = document.createElement('div');
itemEl.className = 'saved-ruta-item';
itemEl.innerHTML = `
<span class="saved-ruta-name">${route.name} (${route.points.length} pts)</span>
<button class="btn-accion btn-cargar-ruta" data-action="load" data-id="${route.id}">📂</button>
<div class="delete-progress"></div>
`;
savedRoutesList.appendChild(itemEl);
const progressBar = itemEl.querySelector('.delete-progress');
let progress = 0;
let intervalId = null;
const cancelDelete = () => {
if (intervalId) {
clearInterval(intervalId);
activeLongPressIntervals.delete(intervalId);
intervalId = null;
}
progress = 0;
progressBar.style.width = '0%';
};
itemEl.addEventListener('touchstart', (e) => {
cancelDelete();
intervalId = setInterval(() => {
progress += 1;
progressBar.style.width = `${progress * 20}%`;
if (progress >= 5) {
clearInterval(intervalId);
activeLongPressIntervals.delete(intervalId);
intervalId = null;
sdk.sendApp({ type: 'delete_saved_route', playerId: playerId, routeId: route.id });
savedRoutes = savedRoutes.filter(r => r.id !== route.id);
renderSavedRoutes();
}
}, 1000);
activeLongPressIntervals.add(intervalId);
}, {passive: true});
itemEl.addEventListener('touchend', cancelDelete);
itemEl.addEventListener('touchcancel', cancelDelete);
itemEl.addEventListener('touchmove', cancelDelete);
const btnLoad = itemEl.querySelector('.btn-cargar-ruta');
btnLoad.addEventListener('click', (e) => {
e.stopPropagation();
cancelDelete();
sdk.sendApp({ type: 'load_saved_route', playerId: playerId, routeId: route.id });
settingsOverlay.classList.remove('visible');
});
btnLoad.addEventListener('touchend', (e) => {
e.stopPropagation();
e.preventDefault();
cancelDelete();
sdk.sendApp({ type: 'load_saved_route', playerId: playerId, routeId: route.id });
settingsOverlay.classList.remove('visible');
});
});
}
return sdk.onApp((data) => {
switch(data.type) {
case 'initial_state':
myRoutes = data.routes || [];
if (typeof data.rotation === 'number') {
serverRotation = data.rotation;
rotationSlider.value = serverRotation;
rotationValue.textContent = `${serverRotation}°`;
}
updateTabs();
break;
case 'route_created': {
const tempRoute = data.tempId
? myRoutes.find(r => r.id === data.tempId)
: null;
if (tempRoute) {
const oldTempId = tempRoute.id;
tempRoute.id = data.route.id;
if (activeRouteId === oldTempId) {
activeRouteId = data.route.id;
}
}
updateTabs();
break;
}
case 'saved_routes_list':
if (data.targetPlayerId === playerId || !data.targetPlayerId) {
savedRoutes = data.routes || [];
renderSavedRoutes();
}
break;
case 'route_loaded':
if (data.targetPlayerId === playerId || !data.targetPlayerId) {
const existingRoute = myRoutes.find(r => r.id === data.route.id);
if (!existingRoute) {
myRoutes.push({ ...data.route, playerId: playerId });
}
activeRouteId = data.route.id;
updateTabs();
}
break;
case 'recover_session':
myRoutes = data.routes || [];
if (myRoutes.length > 0) {
activeRouteId = myRoutes[0].id;
}
// Sincronizamos la rotación con el servidor.
if (typeof data.rotation === 'number') {
serverRotation = data.rotation;
rotationSlider.value = serverRotation;
rotationValue.textContent = `${serverRotation}°`;
}
updateTabs();
break;
case 'map_center':
if (data.targetPlayerId === playerId && data.center) {
panelCoordLat.value = data.center.lat.toFixed(8);
panelCoordLng.value = data.center.lng.toFixed(8);
}
break;
case 'rotate_map':
if (typeof data.rotation === 'number') {
// Ignoramos el eco de nuestro propio mensaje: el slider
// del emisor ya está en ese valor (lo puso el usuario),
// y re-aplicarlo pelea con el dedo mientras arrastra.
if (data.fromPlayerId && data.fromPlayerId === playerId) {
// Solo sincronizamos la copia local por si acaso,
// pero no tocamos el slider.
serverRotation = data.rotation;
break;
}
serverRotation = data.rotation;
rotationSlider.value = serverRotation;
rotationValue.textContent = `${serverRotation}°`;
}
break;
}
});
} .\server.js 40229 caracteres const express = require("express");
const http = require("http");
const WebSocket = require("ws");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const { AppRuntime } = require("./app-runtime");
const { PlayerStore } = require("./player-store");
const { AppProfileStore } = require("./app-profile-store");
const { AppLayoutStore } = require("./app-layout-store");
const HTTP_PORT = 4000;
const PLATFORM_API_VERSION = 1;
const app = express();
function parseCookies(cookieHeader) {
const cookies = {};
if (typeof cookieHeader !== "string" || !cookieHeader) return cookies;
for (const part of cookieHeader.split(";")) {
const separator = part.indexOf("=");
if (separator === -1) continue;
const name = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (!name) continue;
try {
cookies[name] = decodeURIComponent(value);
} catch (_) {
cookies[name] = value;
}
}
return cookies;
}
function isValidProfileId(profileId) {
return (
typeof profileId === "string" &&
profileId.trim() &&
!profileId.includes("/") &&
!profileId.includes("\\") &&
!profileId.includes("..")
);
}
app.use((req, res, next) => {
const cookies = parseCookies(req.headers.cookie);
let profileId = cookies.coreProfileId;
if (!isValidProfileId(profileId)) {
profileId = crypto.randomUUID();
res.setHeader(
"Set-Cookie",
`coreProfileId=${encodeURIComponent(profileId)}; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax`,
);
}
req.coreProfileId = profileId;
next();
});
const httpServer = http.createServer(app);
const wss = new WebSocket.Server({
server: httpServer,
path: "/ws",
});
const playerStore = new PlayerStore();
const appProfileStore = new AppProfileStore();
const appLayoutStore = new AppLayoutStore();
const SETTINGS_FILE = path.join(__dirname, "data", "settings.json");
const DEFAULT_SETTINGS = { maxPlayerNameLength: 20 };
let settings = loadSettings();
function loadSettings() {
try {
if (!fs.existsSync(SETTINGS_FILE)) return { ...DEFAULT_SETTINGS };
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
return { ...DEFAULT_SETTINGS, ...raw };
} catch (err) {
console.error("[Settings] Error loading:", err);
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings() {
try {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 4), "utf8");
} catch (err) {
console.error("[Settings] Error saving:", err);
}
}
function broadcastSettingsChanged() {
broadcast({ type: "settings_changed", settings });
}
function sanitizeName(name) {
if (typeof name !== "string") return null;
const trimmed = name.trim();
if (!trimmed) return null;
const limit = Math.max(1, Number(settings.maxPlayerNameLength) || 20);
return trimmed.slice(0, limit);
}
const APPS_DIR = path.join(__dirname, "apps");
const EXTERNAL_APPS_FILE = path.join(__dirname, "external-apps.json");
function loadNativeApps() {
if (!fs.existsSync(APPS_DIR)) return [];
return fs
.readdirSync(APPS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith("_"))
.map((d) => {
const appId = d.name;
const manifestPath = path.join(APPS_DIR, appId, "app.json");
if (!fs.existsSync(manifestPath)) {
console.warn(`[App] "${appId}" ignored: app.json not found`);
return null;
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!manifest || typeof manifest !== "object") {
throw new Error("Manifest must be an object");
}
if (typeof manifest.name !== "string" || !manifest.name.trim()) {
throw new Error('Missing "name"');
}
if (!manifest.client || typeof manifest.client !== "object") {
throw new Error('Missing "client"');
}
if (
typeof manifest.client.tv !== "string" ||
typeof manifest.client.mobile !== "string"
) {
throw new Error('"client.tv" and "client.mobile" are required');
}
const apiVersion = manifest.apiVersion ?? 1;
if (
!Number.isInteger(apiVersion) ||
apiVersion < 1 ||
apiVersion > PLATFORM_API_VERSION
) {
throw new Error(`Unsupported apiVersion: ${apiVersion}`);
}
return {
...manifest,
id: appId,
apiVersion,
external: false,
};
} catch (err) {
console.error(`[App] Invalid app "${appId}": ${err.message}`);
return null;
}
})
.filter(Boolean);
}
function loadExternalApps() {
if (!fs.existsSync(EXTERNAL_APPS_FILE)) return [];
try {
return JSON.parse(fs.readFileSync(EXTERNAL_APPS_FILE, "utf8")).map((a) => ({
...a,
external: true,
}));
} catch (err) {
console.error("[App] Error loading external apps:", err);
return [];
}
}
function loadApps() {
return [...loadNativeApps(), ...loadExternalApps()];
}
function getNativeAppServerPath(appId) {
const app = loadNativeApps().find((a) => a.id === appId);
if (!app) {
console.error(`[App] Cannot load unknown app "${appId}"`);
return null;
}
const serverPath = path.join(APPS_DIR, appId, "server.js");
if (!fs.existsSync(serverPath)) {
console.error(`[App] Server file not found for "${appId}"`);
return null;
}
return serverPath;
}
let players = [];
const ADMIN_FILE = path.join(__dirname, "data", "admin.json");
let adminSuccessorId = null;
function loadAdminId() {
try {
if (!fs.existsSync(ADMIN_FILE)) return null;
const data = JSON.parse(fs.readFileSync(ADMIN_FILE, "utf8"));
return isValidProfileId(data.adminId) ? data.adminId : null;
} catch (err) {
console.error("[Admin] Error loading admin:", err);
return null;
}
}
function saveAdminId() {
try {
fs.mkdirSync(path.dirname(ADMIN_FILE), { recursive: true });
fs.writeFileSync(
ADMIN_FILE,
JSON.stringify({ adminId }, null, 4),
"utf8",
);
} catch (err) {
console.error("[Admin] Error saving admin:", err);
}
}
let adminId = loadAdminId();
let mainScreenId = null;
let currentFolderId = null;
let currentAppId = null;
let currentAppInstanceId = null;
let currentAppRuntime = null;
// ============================================================
// APP WANTS (quiero jugar a esto)
// ============================================================
const appWants = new Map();
function publicAppWants() {
const result = {};
for (const [appId, profileIds] of appWants) {
const names = [];
for (const profileId of profileIds) {
const p = players.find(
(pl) => pl.profileId === profileId && isConnected(pl) && !pl.suplantador,
);
if (p) {
names.push({ profileId, name: p.name });
}
}
if (names.length > 0) {
result[appId] = names;
}
}
return result;
}
function broadcastAppWantsChanged() {
broadcast({
type: "apps_wants_changed",
wants: publicAppWants(),
});
}
function clearWantsForPlayer(profileId) {
let changed = false;
for (const [appId, set] of appWants) {
if (set.delete(profileId)) {
changed = true;
if (set.size === 0) {
appWants.delete(appId);
}
}
}
if (changed) {
broadcastAppWantsChanged();
}
}
function clearWantsForApp(appId) {
if (appWants.has(appId)) {
appWants.delete(appId);
broadcastAppWantsChanged();
}
}
// ============================================================
// SUPLANTACIÓN
// ============================================================
// Map: connectionId del suplantador -> connectionId del suplantado.
const suplantaciones = new Map();
// Map: profileId bloqueado -> connectionId del suplantador que bloqueó.
const bloqueos = new Map();
// Map: profileId -> objeto de estado local compartido (color, size, etc).
const localStates = new Map();
// ============================================================
function getPlayerByConnectionId(connectionId) {
return players.find((player) => player.connectionId === connectionId);
}
function isConnected(player) {
return !!(player && player.ws && player.ws.readyState === WebSocket.OPEN);
}
function broadcast(data, excludeWs = null) {
const payload = JSON.stringify(data);
players.forEach((player) => {
if (
player.ws &&
player.ws !== excludeWs &&
player.ws.readyState === WebSocket.OPEN
) {
player.ws.send(payload);
}
});
}
function sendTo(connectionId, data) {
const player = getPlayerByConnectionId(connectionId);
if (!isConnected(player)) return;
const payload = JSON.stringify(data);
player.ws.send(payload);
for (const [sConnId, targetConnId] of suplantaciones) {
if (targetConnId === connectionId) {
const s = getPlayerByConnectionId(sConnId);
if (s && isConnected(s)) {
s.ws.send(payload);
}
}
}
}
function broadcastAdminChanged() {
players.forEach((p) => {
if (!isConnected(p)) return;
p.ws.send(
JSON.stringify({
type: "admin_changed",
adminId,
isAdmin: p.profileId === adminId,
}),
);
});
broadcast({ type: "players_update", players: publicPlayers() });
notifyRuntimePlayersChanged();
}
function broadcastAdminSuccessorChanged() {
broadcast({ type: "admin_successor_changed", adminSuccessorId });
broadcast({ type: "players_update", players: publicPlayers() });
}
function publicPlayers() {
return players
.filter((p) => isConnected(p) && !p.suplantador)
.map(getPublicPlayer);
}
function getPublicPlayer(player) {
if (!player) return null;
return {
id: player.connectionId,
connectionId: player.connectionId,
profileId: player.profileId,
name: player.name,
role: player.role,
isAdmin: player.profileId === adminId,
isAdminSuccessor: player.profileId === adminSuccessorId,
connected: isConnected(player),
};
}
function assignMainScreen() {
const current = getPlayerByConnectionId(mainScreenId);
if (current && current.role === "tv" && isConnected(current)) return;
const next = players.find(
(player) => player.role === "tv" && isConnected(player),
);
mainScreenId = next ? next.connectionId : null;
broadcast({ type: "main_screen_changed", mainScreenId });
broadcast({ type: "players_update", players: publicPlayers() });
}
function assignNewAdmin() {
const connectedMobiles = players.filter(
(player) => player.role === "mobile" && isConnected(player) && !player.suplantador,
);
if (
adminId &&
connectedMobiles.some((player) => player.profileId === adminId)
) {
return;
}
if (connectedMobiles.length === 0) return;
if (adminSuccessorId) {
const successor = connectedMobiles.find(
(player) => player.profileId === adminSuccessorId,
);
if (successor) {
adminId = successor.profileId;
adminSuccessorId = null;
saveAdminId();
broadcastAdminChanged();
broadcastAdminSuccessorChanged();
return;
}
}
let next = connectedMobiles.find((player) => player.profileId !== adminId);
if (!next) next = connectedMobiles[0];
adminId = next.profileId;
if (adminSuccessorId === next.profileId) {
adminSuccessorId = null;
}
saveAdminId();
broadcastAdminChanged();
if (adminSuccessorId === null) {
broadcastAdminSuccessorChanged();
}
}
function createAppRuntime(app, instanceId) {
const serverPath = getNativeAppServerPath(app.id);
if (!serverPath) return null;
return new AppRuntime({
app,
instanceId,
serverPath,
playerStore,
appProfileStore,
players: publicPlayers(),
onBroadcast(data) {
broadcast({
type: "app_message",
appId: app.id,
instanceId,
data,
});
},
onSendTo(connectionId, data) {
sendTo(connectionId, {
type: "app_message",
appId: app.id,
instanceId,
data,
});
},
onLog(...args) {
console.log(`[App:${app.id}]`, ...args);
},
onError(err) {
console.error(`[AppRuntime:${app.id}]`, err);
},
});
}
async function closeCurrentApp() {
if (!currentAppRuntime) {
currentAppId = null;
currentAppInstanceId = null;
return;
}
const runtime = currentAppRuntime;
currentAppRuntime = null;
currentAppId = null;
currentAppInstanceId = null;
try {
await runtime.stop();
} catch (err) {
console.error("[AppRuntime] Error stopping app:", err);
}
}
async function handleRuntimeError(runtime, error) {
if (runtime !== currentAppRuntime) return;
console.error(`[AppRuntime] Current app "${runtime.app.id}" failed:`, error);
currentAppRuntime = null;
currentAppId = null;
currentAppInstanceId = null;
try {
await runtime.stop();
} catch (_) {}
broadcast({
type: "app_error",
message: "The current app stopped unexpectedly",
});
broadcast({ type: "app_selected", app: null, instanceId: null });
}
async function selectApp(appId) {
const apps = loadApps();
const app = apps.find((a) => a.id === appId);
if (!app) return false;
if (currentAppId === appId) return true;
await closeCurrentApp();
clearWantsForApp(appId);
if (app.external) {
currentAppId = appId;
currentAppInstanceId = crypto.randomUUID();
broadcast({
type: "app_selected",
app,
instanceId: currentAppInstanceId,
});
return true;
}
const instanceId = crypto.randomUUID();
const runtime = createAppRuntime(app, instanceId);
if (!runtime) return false;
currentAppId = appId;
currentAppInstanceId = instanceId;
currentAppRuntime = runtime;
broadcast({ type: "app_selected", app, instanceId });
runtime.onError = (error) => {
handleRuntimeError(runtime, error);
};
try {
await runtime.start();
} catch (err) {
await handleRuntimeError(runtime, err);
return false;
}
return true;
}
function handleAppMessage(player, data) {
if (!currentAppRuntime) return;
if (data.appId && data.appId !== currentAppId) return;
if (data.instanceId && data.instanceId !== currentAppInstanceId) return;
currentAppRuntime.sendMessage(data.data || {}, getPublicPlayer(player));
}
function notifyRuntimePlayersChanged() {
if (!currentAppRuntime) return;
currentAppRuntime.updatePlayers(publicPlayers());
}
function notifyPlayerConnected(player) {
if (!currentAppRuntime) return;
currentAppRuntime.playerConnected(getPublicPlayer(player));
}
function notifyPlayerJoined(player) {
if (!currentAppRuntime) return;
currentAppRuntime.playerJoined(getPublicPlayer(player));
}
function notifyPlayerDisconnected(player) {
if (!currentAppRuntime) return;
currentAppRuntime.playerDisconnected({
...getPublicPlayer(player),
connected: false,
});
}
function notifyPlayerLeft(player) {
if (!currentAppRuntime) return;
currentAppRuntime.playerLeft(getPublicPlayer(player));
}
function getTvAppLayout() {
const apps = loadApps();
if (adminId && playerStore.get(adminId)) {
return appLayoutStore.get(adminId, apps);
}
return {
version: 1,
root: apps.map((app) => ({ type: "app", id: app.id })),
};
}
function findInvalidApp(layout, installedAppIds) {
function find(items) {
for (const item of items) {
if (item.type === "app") {
if (!installedAppIds.has(item.id)) return item.id;
continue;
}
if (item.type === "folder") {
const invalid = find(item.children || []);
if (invalid) return invalid;
}
}
return null;
}
return find(layout.root);
}
function findFolderInLayout(items, folderId) {
for (const item of items) {
if (item.type === "folder" && item.id === folderId) return item;
if (item.type === "folder") {
const result = findFolderInLayout(item.children || [], folderId);
if (result) return result;
}
}
return null;
}
const PLATFORM_TYPES = new Set([
"identify",
"list_apps",
"get_app_layout",
"set_app_layout",
"select_app",
"select_folder",
"exit_app",
"set_name",
"set_player_name",
"set_main_screen",
"set_max_name_length",
"transfer_admin",
"set_admin_successor",
"clear_admin_successor",
"gamepad_message",
"sensor_message",
"want_app",
"no_want_app",
"lock_player",
"unlock_player",
"local_state",
]);
wss.on("connection", (ws, request) => {
let player = null;
const cookies = parseCookies(request.headers.cookie);
const cookieProfileId = cookies.coreProfileId;
ws.on("message", async (raw) => {
let data;
try {
data = JSON.parse(raw);
} catch (_) {
return;
}
if (data.type === "identify") {
const role = data.role === "tv" ? "tv" : "mobile";
// --------------------------------------------------------
// SUPLANTACIÓN
// --------------------------------------------------------
if (data.suplantarConnectionId) {
const target = getPlayerByConnectionId(data.suplantarConnectionId);
if (!target || !isConnected(target) || target.suplantador) {
ws.send(
JSON.stringify({
type: "error",
message: "Player to suplant is not connected",
}),
);
ws.close();
return;
}
player = {
connectionId: crypto.randomUUID(),
profileId: target.profileId,
ws,
name: target.name,
role: "mobile",
suplantador: true,
suplantadoConnectionId: target.connectionId,
};
players.push(player);
suplantaciones.set(player.connectionId, target.connectionId);
const suplantadoProfile = playerStore.get(target.profileId);
ws.send(
JSON.stringify({
type: "init",
playerId: player.connectionId,
connectionId: player.connectionId,
profileId: player.profileId,
isAdmin: player.profileId === adminId,
adminId,
adminSuccessorId,
mainScreenId,
players: publicPlayers(),
apps: loadApps(),
appLayout: appLayoutStore.get(player.profileId, loadApps()),
appWants: publicAppWants(),
lockedPlayers: Array.from(bloqueos.keys()),
localState: localStates.get(player.profileId) || {},
currentAppId,
currentAppInstanceId,
folderId: currentFolderId,
apiVersion: PLATFORM_API_VERSION,
settings,
suplantando: {
profileId: target.profileId,
name: target.name,
connectionId: target.connectionId,
},
profile: suplantadoProfile
? {
id: suplantadoProfile.id,
name: suplantadoProfile.name,
names: suplantadoProfile.names || {},
created: suplantadoProfile.created,
lastSeen: suplantadoProfile.lastSeen,
}
: {
id: target.profileId,
name: target.name,
names: {},
},
}),
);
return;
}
// --------------------------------------------------------
// NORMAL
// --------------------------------------------------------
let profile = null;
let existingPlayer = null;
const requestedProfileId = cookieProfileId || data.profileId;
if (isValidProfileId(requestedProfileId)) {
profile = playerStore.get(requestedProfileId);
}
if (profile) {
existingPlayer = players.find(
(p) => p.profileId === profile.id && p.role === role && !p.suplantador,
);
}
if (profile) {
playerStore.touch(profile.id);
const roleName = playerStore.getName(profile.id, role);
if (data.name && data.name.trim()) {
if (!playerStore.get(profile.id)?.names?.[role]) {
playerStore.setNameForRole(profile.id, role, data.name.trim());
}
profile = playerStore.get(profile.id);
}
const finalName =
playerStore.getName(profile.id, role) || roleName || profile.name;
if (existingPlayer) {
if (existingPlayer.ws && existingPlayer.ws !== ws) {
try {
existingPlayer.ws.close();
} catch (_) {}
}
existingPlayer.ws = ws;
if (!existingPlayer.name) existingPlayer.name = finalName;
player = existingPlayer;
console.log(`Connection recovered: ${profile.name} (${profile.id})`);
} else {
player = {
connectionId: crypto.randomUUID(),
profileId: profile.id,
ws,
name: finalName,
role,
isAdmin: profile.id === adminId,
};
players.push(player);
console.log(`Profile connected: ${profile.name} (${profile.id})`);
}
} else {
const defaultName =
data.name || (role === "tv" ? "TV" : "Player");
const newProfileId = isValidProfileId(cookieProfileId)
? cookieProfileId
: crypto.randomUUID();
profile = playerStore.create(newProfileId, { name: defaultName });
playerStore.setNameForRole(profile.id, role, defaultName);
profile = playerStore.get(profile.id);
player = {
connectionId: crypto.randomUUID(),
profileId: profile.id,
ws,
name: defaultName,
role,
isAdmin: false,
};
players.push(player);
console.log(`New profile: ${profile.name} (${profile.id})`);
}
if (role === "mobile") {
if (data.forceAdmin === true) {
adminId = player.profileId;
saveAdminId();
if (adminSuccessorId !== null) {
adminSuccessorId = null;
broadcastAdminSuccessorChanged();
}
broadcastAdminChanged();
} else {
assignNewAdmin();
if (!adminId) {
adminId = player.profileId;
saveAdminId();
broadcastAdminChanged();
if (adminSuccessorId !== null) {
adminSuccessorId = null;
broadcastAdminSuccessorChanged();
}
}
}
}
if (role === "tv") {
assignMainScreen();
}
ws.send(
JSON.stringify({
type: "init",
playerId: player.connectionId,
connectionId: player.connectionId,
profileId: player.profileId,
isAdmin: player.profileId === adminId,
adminId,
adminSuccessorId,
mainScreenId,
players: publicPlayers(),
apps: loadApps(),
appLayout:
player.role === "tv"
? getTvAppLayout()
: appLayoutStore.get(player.profileId, loadApps()),
appWants: publicAppWants(),
lockedPlayers: Array.from(bloqueos.keys()),
localState: localStates.get(player.profileId) || {},
currentAppId,
currentAppInstanceId,
folderId: currentFolderId,
apiVersion: PLATFORM_API_VERSION,
settings,
profile: {
id: profile.id,
name: profile.name,
names: profile.names || {},
created: profile.created,
lastSeen: profile.lastSeen,
},
}),
);
broadcast(
{
type: "player_joined",
player: getPublicPlayer(player),
adminId,
},
ws,
);
broadcast({ type: "players_update", players: publicPlayers() });
if (existingPlayer) {
notifyPlayerConnected(player);
} else {
notifyPlayerJoined(player);
notifyPlayerConnected(player);
}
notifyRuntimePlayersChanged();
return;
}
if (!player) return;
// --------------------------------------------------------
// BLOQUEO
// --------------------------------------------------------
//
// Si el jugador está bloqueado y este mensaje viene de su
// conexión original (no de un suplantador), ignorar.
if (bloqueos.has(player.profileId) && !player.suplantador) {
return;
}
if (PLATFORM_TYPES.has(data.type)) {
if (data.type === "list_apps") {
ws.send(JSON.stringify({ type: "apps_list", apps: loadApps() }));
return;
}
if (data.type === "get_app_layout") {
const apps = loadApps();
const appLayout =
player.role === "tv"
? getTvAppLayout()
: appLayoutStore.get(player.profileId, apps);
ws.send(JSON.stringify({ type: "app_layout", layout: appLayout }));
return;
}
if (data.type === "set_app_layout") {
if (!data.layout) {
ws.send(
JSON.stringify({ type: "error", message: "App layout is required" }),
);
return;
}
const apps = loadApps();
const installedAppIds = new Set(apps.map((app) => app.id));
try {
const layout = appLayoutStore.validate(data.layout);
const invalidApp = findInvalidApp(layout, installedAppIds);
if (invalidApp) {
throw new Error(`App "${invalidApp}" is not installed`);
}
const savedLayout = appLayoutStore.save(
player.profileId,
layout,
apps,
);
if (
currentFolderId &&
!findFolderInLayout(savedLayout.root, currentFolderId)
) {
currentFolderId = null;
broadcast({ type: "folder_selected", folderId: null });
}
ws.send(
JSON.stringify({ type: "app_layout", layout: savedLayout }),
);
if (player.profileId === adminId) {
players.forEach((tvPlayer) => {
if (tvPlayer.role === "tv" && isConnected(tvPlayer)) {
tvPlayer.ws.send(
JSON.stringify({
type: "app_layout",
layout: savedLayout,
}),
);
}
});
}
} catch (err) {
ws.send(JSON.stringify({ type: "error", message: err.message }));
}
return;
}
if (data.type === "select_app") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can choose the app",
}),
);
return;
}
const ok = await selectApp(data.appId);
if (!ok) {
ws.send(
JSON.stringify({
type: "error",
message: "App could not be started",
}),
);
}
return;
}
if (data.type === "select_folder") {
if (player.profileId !== adminId || player.role !== "mobile") {
return;
}
if (data.folderId === null || data.folderId === undefined) {
currentFolderId = null;
broadcast({ type: "folder_selected", folderId: null });
return;
}
if (typeof data.folderId !== "string") return;
const layout = appLayoutStore.get(player.profileId, loadApps());
const folder = findFolderInLayout(layout.root, data.folderId);
if (!folder) return;
currentFolderId = folder.id;
broadcast({ type: "folder_selected", folderId: currentFolderId });
return;
}
if (data.type === "exit_app") {
if (player.profileId !== adminId) return;
await closeCurrentApp();
broadcast({ type: "app_selected", app: null, instanceId: null });
return;
}
if (data.type === "set_name") {
const name = sanitizeName(data.name);
if (!name) return;
playerStore.setNameForRole(player.profileId, player.role, name);
players.forEach((p) => {
if (p.profileId === player.profileId && !p.suplantador) {
p.name = name;
}
});
broadcast({ type: "players_update", players: publicPlayers() });
broadcast({
type: "player_renamed",
connectionId: player.connectionId,
profileId: player.profileId,
role: player.role,
name,
});
broadcastAppWantsChanged();
notifyRuntimePlayersChanged();
return;
}
if (data.type === "set_player_name") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can rename other players",
}),
);
return;
}
const targetConnectionId = data.connectionId;
if (typeof targetConnectionId !== "string") return;
const target = getPlayerByConnectionId(targetConnectionId);
if (!target || target.suplantador) return;
const name = sanitizeName(data.name);
if (!name) return;
playerStore.setNameForRole(target.profileId, target.role, name);
players.forEach((p) => {
if (p.profileId === target.profileId && !p.suplantador) {
p.name = name;
}
});
broadcast({ type: "players_update", players: publicPlayers() });
broadcast({
type: "player_renamed",
connectionId: target.connectionId,
profileId: target.profileId,
role: target.role,
name,
});
broadcastAppWantsChanged();
notifyRuntimePlayersChanged();
return;
}
if (data.type === "set_main_screen") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can set the main screen",
}),
);
return;
}
const targetConnectionId = data.connectionId;
if (typeof targetConnectionId !== "string") return;
const target = getPlayerByConnectionId(targetConnectionId);
if (!target || target.role !== "tv" || !isConnected(target)) return;
mainScreenId = target.connectionId;
broadcast({ type: "main_screen_changed", mainScreenId });
broadcast({ type: "players_update", players: publicPlayers() });
return;
}
if (data.type === "set_max_name_length") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can change settings",
}),
);
return;
}
const value = Number(data.value);
if (!Number.isInteger(value) || value < 1 || value > 100) {
ws.send(
JSON.stringify({
type: "error",
message: "maxPlayerNameLength must be between 1 and 100",
}),
);
return;
}
settings.maxPlayerNameLength = value;
saveSettings();
broadcastSettingsChanged();
return;
}
if (data.type === "transfer_admin") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can transfer the admin",
}),
);
return;
}
const targetProfileId = data.targetProfileId;
if (!isValidProfileId(targetProfileId)) return;
const target = players.find(
(p) =>
p.profileId === targetProfileId &&
p.role === "mobile" &&
isConnected(p) &&
!p.suplantador,
);
if (!target) {
ws.send(
JSON.stringify({
type: "error",
message: "Target player is not connected",
}),
);
return;
}
if (targetProfileId === adminId) return;
adminId = targetProfileId;
if (adminSuccessorId === targetProfileId) {
adminSuccessorId = null;
}
saveAdminId();
broadcastAdminChanged();
if (adminSuccessorId === null) {
broadcastAdminSuccessorChanged();
}
return;
}
if (data.type === "set_admin_successor") {
if (player.profileId !== adminId) {
ws.send(
JSON.stringify({
type: "error",
message: "Only admin can set a successor",
}),
);
return;
}
const targetProfileId = data.targetProfileId;
if (!isValidProfileId(targetProfileId)) return;
const target = players.find(
(p) =>
p.profileId === targetProfileId &&
p.role === "mobile" &&
isConnected(p) &&
!p.suplantador,
);
if (!target) {
ws.send(
JSON.stringify({
type: "error",
message: "Target player is not connected",
}),
);
return;
}
if (targetProfileId === adminId) return;
adminSuccessorId = targetProfileId;
broadcastAdminSuccessorChanged();
return;
}
if (data.type === "clear_admin_successor") {
if (player.profileId !== adminId) return;
adminSuccessorId = null;
broadcastAdminSuccessorChanged();
return;
}
if (data.type === "want_app") {
const appId = data.appId;
if (typeof appId !== "string") return;
if (player.role !== "mobile") return;
if (!appWants.has(appId)) {
appWants.set(appId, new Set());
}
appWants.get(appId).add(player.profileId);
broadcastAppWantsChanged();
return;
}
if (data.type === "no_want_app") {
const appId = data.appId;
if (typeof appId !== "string") return;
const set = appWants.get(appId);
if (set) {
set.delete(player.profileId);
if (set.size === 0) {
appWants.delete(appId);
}
}
broadcastAppWantsChanged();
return;
}
if (data.type === "local_state") {
if (!data.state || typeof data.state !== "object") return;
const current = localStates.get(player.profileId) || {};
const next = { ...current, ...data.state };
localStates.set(player.profileId, next);
const targets = players.filter(
(p) => p.profileId === player.profileId && isConnected(p),
);
for (const t of targets) {
t.ws.send(
JSON.stringify({
type: "local_state",
profileId: player.profileId,
state: next,
}),
);
}
return;
}
if (data.type === "lock_player") {
if (!player.suplantador) return;
bloqueos.set(player.profileId, player.connectionId);
broadcast({
type: "player_locked",
profileId: player.profileId,
});
return;
}
if (data.type === "unlock_player") {
if (!player.suplantador) return;
bloqueos.delete(player.profileId);
broadcast({
type: "player_unlocked",
profileId: player.profileId,
});
return;
}
if (data.type === "gamepad_message") {
if (!currentAppRuntime) return;
if (!data.data || typeof data.data !== "object") return;
const payload = {
...data.data,
playerId: player.connectionId,
profileId: player.profileId,
playerRole: player.role,
};
if (!payload.type) {
payload.type = "gamepad_event";
}
currentAppRuntime.sendMessage(payload, getPublicPlayer(player));
return;
}
if (data.type === "sensor_message") {
if (!currentAppRuntime) return;
if (!data.data || typeof data.data !== "object") return;
const payload = {
...data.data,
playerId: player.connectionId,
profileId: player.profileId,
playerRole: player.role,
};
if (!payload.type) {
payload.type = "sensor_event";
}
currentAppRuntime.sendMessage(payload, getPublicPlayer(player));
return;
}
return;
}
if (data.type === "app_message") {
handleAppMessage(player, data);
}
});
ws.on("close", async () => {
if (!player) return;
if (player.ws !== ws) return;
player.ws = null;
// --------------------------------------------------------
// Si era suplantador, limpiar suplantación y bloqueos.
// --------------------------------------------------------
if (player.suplantador) {
suplantaciones.delete(player.connectionId);
for (const [profileId, connId] of bloqueos) {
if (connId === player.connectionId) {
bloqueos.delete(profileId);
broadcast({
type: "player_unlocked",
profileId,
});
}
}
return;
}
// --------------------------------------------------------
// Si era el suplantado, cerrar sus suplantadores.
// --------------------------------------------------------
for (const [sConnId, targetConnId] of [...suplantaciones]) {
if (targetConnId === player.connectionId) {
const s = getPlayerByConnectionId(sConnId);
if (s && s.ws) {
try {
s.ws.close();
} catch (_) {}
}
suplantaciones.delete(sConnId);
}
}
clearWantsForPlayer(player.profileId);
broadcast({
type: "player_left",
playerId: player.connectionId,
profileId: player.profileId,
role: player.role,
players: publicPlayers(),
});
notifyPlayerDisconnected(player);
notifyRuntimePlayersChanged();
if (player.profileId === adminId) {
assignNewAdmin();
} else if (player.profileId === adminSuccessorId) {
adminSuccessorId = null;
broadcastAdminSuccessorChanged();
}
if (player.connectionId === mainScreenId) {
assignMainScreen();
}
const remainingNonSuplantadores = players.filter(
(p) => !p.suplantador && isConnected(p),
);
if (remainingNonSuplantadores.length === 0) {
await closeCurrentApp();
mainScreenId = null;
localStates.clear();
if (adminSuccessorId !== null) {
adminSuccessorId = null;
}
}
});
});
// ============================================================
// HTTP ROUTES
// ============================================================
app.use(express.static(path.join(__dirname, "public")));
app.use("/apps", express.static(APPS_DIR));
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "public", "mobile.html"));
});
app.get("/a", (req, res) => {
res.redirect(302, "/?admin=1");
});
app.get("/tv", (req, res) => {
res.sendFile(path.join(__dirname, "public", "tv.html"));
});
app.get("/control", (req, res) => {
res.sendFile(path.join(__dirname, "public", "control.html"));
});
app.get("/control/players", (req, res) => {
res.json(publicPlayers());
});
app.get("/suplantar", (req, res) => {
res.sendFile(path.join(__dirname, "public", "mobile.html"));
});
httpServer.listen(HTTP_PORT, () => {
console.log(`🌐 Web platform at http://localhost:${HTTP_PORT}`);
console.log(`📱 Controller: http://localhost:${HTTP_PORT}/`);
console.log(`🖥️ TV: http://localhost:${HTTP_PORT}/tv`);
console.log(`🎮 Control: http://localhost:${HTTP_PORT}/control`);
console.log(`🔌 WebSocket on ws://localhost:${HTTP_PORT}/ws`);
}); .\apps\maps\server.js 44222 caracteres const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath =
path.join(
__dirname,
'rutas.db'
);
// ============================================================
// DATABASE
// ============================================================
const db =
new sqlite3.Database(
dbPath,
err => {
if (err) {
console.error(
'❌ Error opening database:',
err
);
} else {
console.log(
'✅ Database connected at',
dbPath
);
}
}
);
db.on(
'error',
err => {
console.error(
'❌ Database error (caught, server keeps running):',
err
);
}
);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS rutas (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
color TEXT DEFAULT '#a78bfa',
puntos TEXT DEFAULT '[]',
visible INTEGER DEFAULT 1,
por_carretera INTEGER DEFAULT 1,
mostrar_marcadores INTEGER DEFAULT 1,
mostrar_lineas INTEGER DEFAULT 1,
timestamp INTEGER
)
`);
db.run(
`ALTER TABLE rutas ADD COLUMN por_carretera INTEGER DEFAULT 1`,
() => {}
);
db.run(
`ALTER TABLE rutas ADD COLUMN mostrar_marcadores INTEGER DEFAULT 1`,
() => {}
);
db.run(
`ALTER TABLE rutas ADD COLUMN mostrar_lineas INTEGER DEFAULT 1`,
() => {}
);
db.run(
`ALTER TABLE rutas ADD COLUMN timestamp INTEGER`,
() => {}
);
});
// ============================================================
// STATE
// ============================================================
let routes = [];
let routeCounter = 0;
// Rotación actual del mapa en la TV (grados).
// Se sincroniza entre todos los clientes para que el touchpad
// del móvil pueda calcular bien el ángulo al mover.
let mapRotation = 0;
// ============================================================
// DATABASE HELPERS
// ============================================================
function loadRoutesDB() {
return new Promise(
resolve => {
db.all(
'SELECT * FROM rutas ORDER BY timestamp ASC',
(err, rows) => {
if (err) {
console.error(
'Error loading routes:',
err
);
resolve([]);
return;
}
const loadedRoutes =
rows.map(
row => {
let points = [];
try {
points =
JSON.parse(
row.puntos ||
'[]'
);
} catch (error) {
console.error(
`Error parsing route ${row.id}:`,
error
);
points = [];
}
points.forEach(
point => {
if (
point.invertType ===
undefined
) {
point.invertType =
false;
}
if (
point.tipoSegmento !==
undefined
) {
delete point.tipoSegmento;
}
}
);
return {
id:
row.id,
name:
row.nombre,
color:
row.color,
points,
visible:
row.visible === 1,
byRoad:
row.por_carretera === 1,
showMarkers:
row.mostrar_marcadores === 1,
showLines:
row.mostrar_lineas === 1,
timestamp:
row.timestamp
};
}
);
resolve(
loadedRoutes
);
}
);
}
);
}
function saveRouteDB(
route
) {
try {
const {
playerId,
...routeWithoutPlayer
} = route;
const cleanPoints =
(
routeWithoutPlayer.points ||
[]
).map(
point => {
const cleanPoint = {
...point
};
if (
cleanPoint.invertType ===
undefined
) {
cleanPoint.invertType =
false;
}
if (
cleanPoint.tipoSegmento !==
undefined
) {
delete cleanPoint.tipoSegmento;
}
return cleanPoint;
}
);
db.run(
`
INSERT OR REPLACE INTO rutas
(
id,
nombre,
color,
puntos,
visible,
por_carretera,
mostrar_marcadores,
mostrar_lineas,
timestamp
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
routeWithoutPlayer.id,
routeWithoutPlayer.name,
routeWithoutPlayer.color,
JSON.stringify(
cleanPoints
),
routeWithoutPlayer.visible
? 1
: 0,
routeWithoutPlayer.byRoad
? 1
: 0,
routeWithoutPlayer.showMarkers !==
false
? 1
: 0,
routeWithoutPlayer.showLines !==
false
? 1
: 0,
routeWithoutPlayer.timestamp ||
Date.now()
],
err => {
if (err) {
console.error(
'Error saving route:',
err
);
} else {
console.log(
`✅ Route ${routeWithoutPlayer.name} saved to DB`
);
}
}
);
} catch (error) {
console.error(
'Unexpected error saving route (caught):',
error
);
}
}
function deleteRouteDB(
routeId
) {
db.run(
'DELETE FROM rutas WHERE id = ?',
[
routeId
],
err => {
if (err) {
console.error(
'Error deleting route:',
err
);
} else {
console.log(
`🗑️ Route ${routeId} deleted from DB`
);
}
}
);
}
function clearRoutesDB() {
db.run(
'DELETE FROM rutas',
err => {
if (err) {
console.error(
'Error clearing routes:',
err
);
} else {
console.log(
'🧹 All routes deleted from DB'
);
}
}
);
}
// ============================================================
// ROUTE HELPERS
// ============================================================
function cleanPoints(
points
) {
return (
points || []
).map(
point => {
const cleanPoint = {
...point
};
if (
cleanPoint.invertType ===
undefined
) {
cleanPoint.invertType =
false;
}
if (
cleanPoint.tipoSegmento !==
undefined
) {
delete cleanPoint.tipoSegmento;
}
return cleanPoint;
}
);
}
// ============================================================
// APP
// ============================================================
module.exports =
function createApp(ctx) {
return {
// ====================================================
// START
// ====================================================
async onStart() {
console.log(
'🚀 Maps app started'
);
routes = [];
routeCounter = 0;
mapRotation = 0;
console.log(
'📦 Clean map, waiting for user to load routes'
);
ctx.broadcast({
type:
'initial_state',
routes: [],
rotation:
mapRotation,
message:
'Clean map - Load routes from the panel'
});
},
// ====================================================
// PLAYER JOINED
// ====================================================
async onPlayerJoined(
player
) {
console.log(
'👤 Player joined:',
player.id,
player.role
);
if (
player.role === 'tv'
) {
ctx.sendTo(
player.id,
{
type:
'initial_state',
routes:
routes,
rotation:
mapRotation,
message:
`Map with ${routes.length} active routes`
}
);
}
if (
player.role === 'mobile'
) {
ctx.sendTo(
player.id,
{
type:
'recover_session',
routes:
routes,
rotation:
mapRotation,
message:
`Active routes: ${routes.length}`
}
);
}
},
// ====================================================
// PLAYER CONNECTED
// ====================================================
async onPlayerConnected(
player
) {
console.log(
'🔄 Player connected:',
player.id,
player.role
);
// Needed for profile/connection recovery.
if (
player.role === 'tv'
) {
ctx.sendTo(
player.id,
{
type:
'initial_state',
routes:
routes,
rotation:
mapRotation,
message:
`Map with ${routes.length} active routes`
}
);
}
if (
player.role === 'mobile'
) {
ctx.sendTo(
player.id,
{
type:
'recover_session',
routes:
routes,
rotation:
mapRotation,
message:
`Active routes: ${routes.length}`
}
);
}
},
// ====================================================
// PLAYER DISCONNECTED
// ====================================================
async onPlayerDisconnected(
player
) {
console.log(
'🔌 Player disconnected:',
player.id,
player.role
);
},
// ====================================================
// PLAYER LEFT
// ====================================================
async onPlayerLeft(
player
) {
console.log(
'👋 Player left:',
player.id,
player.role
);
},
// ====================================================
// MESSAGES
// ====================================================
async onMessage(
data,
player
) {
const playerId =
player.id;
try {
switch (
data.type
) {
// ========================================
// CREATE ROUTE
// ========================================
case 'create_route': {
routeCounter++;
const points =
cleanPoints(
data.points
);
const newRoute = {
id:
`route_${routeCounter}_${Date.now()}`,
playerId,
name:
data.name ||
`Route ${routeCounter}`,
color:
data.color ||
'#a78bfa',
points,
visible:
true,
byRoad:
data.byRoad !==
undefined
? data.byRoad
: true,
showMarkers:
true,
showLines:
true,
timestamp:
Date.now()
};
routes.push(
newRoute
);
ctx.broadcast({
type:
'route_created',
route:
newRoute,
tempId:
data.tempId,
createdBy:
playerId
});
return;
}
// ========================================
// UPDATE ROUTE
// ========================================
case 'update_route': {
const routeIndex =
routes.findIndex(
r =>
r.id ===
data.routeId
);
if (
routeIndex ===
-1
) {
return;
}
if (
data.points !==
undefined
) {
routes[
routeIndex
].points =
cleanPoints(
data.points
);
}
if (
data.name !==
undefined
) {
routes[
routeIndex
].name =
data.name;
}
if (
data.color !==
undefined
) {
routes[
routeIndex
].color =
data.color;
}
if (
data.byRoad !==
undefined
) {
routes[
routeIndex
].byRoad =
data.byRoad;
}
if (
data.showMarkers !==
undefined
) {
routes[
routeIndex
].showMarkers =
data.showMarkers;
}
if (
data.showLines !==
undefined
) {
routes[
routeIndex
].showLines =
data.showLines;
}
ctx.broadcast({
type:
'route_updated',
route:
routes[
routeIndex
]
});
return;
}
// ========================================
// SAVE ROUTE
// ========================================
case 'save_route': {
const routeToSave =
routes.find(
r =>
r.id ===
data.routeId
);
if (
routeToSave
) {
if (
data.name !==
undefined
) {
routeToSave.name =
data.name;
}
if (
data.points !==
undefined
) {
routeToSave.points =
cleanPoints(
data.points
);
}
if (
data.color !==
undefined
) {
routeToSave.color =
data.color;
}
if (
data.byRoad !==
undefined
) {
routeToSave.byRoad =
data.byRoad;
}
if (
data.visible !==
undefined
) {
routeToSave.visible =
data.visible;
}
if (
data.showMarkers !==
undefined
) {
routeToSave.showMarkers =
data.showMarkers;
}
if (
data.showLines !==
undefined
) {
routeToSave.showLines =
data.showLines;
}
saveRouteDB(
routeToSave
);
console.log(
`💾 Route saved to DB: ${routeToSave.name}`
);
ctx.sendTo(
playerId,
{
type:
'route_saved_confirmation',
routeId:
routeToSave.id,
message:
'✅ Route saved successfully'
}
);
} else {
console.warn(
`⚠️ save_route: no active route found with id ${data.routeId}`
);
}
return;
}
// ========================================
// REQUEST SAVED ROUTES
// ========================================
case 'request_saved_routes': {
console.log(
'📋 Requesting saved routes for the panel'
);
loadRoutesDB()
.then(
allRoutes => {
console.log(
`📤 Sending ${allRoutes.length} saved routes to the panel`
);
ctx.sendTo(
playerId,
{
type:
'saved_routes_list',
routes:
allRoutes,
targetPlayerId:
playerId
}
);
}
);
return;
}
// ========================================
// LOAD SAVED ROUTE
// ========================================
case 'load_saved_route': {
console.log(
'📂 Loading saved route:',
data.routeId
);
loadRoutesDB()
.then(
allRoutes => {
const loadedRoute =
allRoutes.find(
r =>
r.id ===
data.routeId
);
if (
!loadedRoute
) {
return;
}
console.log(
'✅ Route found in DB, loading to map...'
);
const routeWithPlayer = {
...loadedRoute,
points:
cleanPoints(
loadedRoute.points
),
playerId
};
const routeInMemory =
routes.find(
r =>
r.id ===
loadedRoute.id
);
if (
routeInMemory
) {
Object.assign(
routeInMemory,
routeWithPlayer
);
} else {
routes.push(
routeWithPlayer
);
}
ctx.sendTo(
playerId,
{
type:
'route_loaded',
route:
routeWithPlayer,
targetPlayerId:
playerId
}
);
ctx.players.forEach(
p => {
if (
p.role ===
'tv'
) {
ctx.sendTo(
p.id,
{
type:
'route_loaded_tv',
route:
routeWithPlayer
}
);
}
}
);
}
);
return;
}
// ========================================
// DELETE SAVED ROUTE
// ========================================
case 'delete_saved_route': {
deleteRouteDB(
data.routeId
);
return;
}
// ========================================
// DELETE ACTIVE ROUTE
// ========================================
case 'delete_route': {
routes =
routes.filter(
r =>
r.id !==
data.routeId
);
ctx.broadcast({
type:
'route_deleted',
routeId:
data.routeId
});
return;
}
// ========================================
// TOGGLE ROUTE VISIBILITY
// ========================================
case 'toggle_route_visibility': {
const routeIndex =
routes.findIndex(
r =>
r.id ===
data.routeId
);
if (
routeIndex !==
-1
) {
routes[
routeIndex
].visible =
!routes[
routeIndex
].visible;
ctx.broadcast({
type:
'route_visibility_changed',
route:
routes[
routeIndex
]
});
}
return;
}
// ========================================
// TOGGLE ROUTE MARKERS
// ========================================
case 'toggle_route_markers': {
const routeIndex =
routes.findIndex(
r =>
r.id ===
data.routeId
);
if (
routeIndex !==
-1
) {
routes[
routeIndex
].showMarkers =
data.show;
ctx.broadcast({
type:
'toggle_route_markers',
routeId:
data.routeId,
show:
data.show
});
}
return;
}
// ========================================
// TOGGLE ROUTE LINES
// ========================================
case 'toggle_route_lines': {
const routeIndex =
routes.findIndex(
r =>
r.id ===
data.routeId
);
if (
routeIndex !==
-1
) {
routes[
routeIndex
].showLines =
data.show;
ctx.broadcast({
type:
'toggle_route_lines',
routeId:
data.routeId,
show:
data.show
});
}
return;
}
// ========================================
// CLEAR ALL ROUTES
// ========================================
case 'clear_all_routes': {
routes = [];
clearRoutesDB();
ctx.broadcast({
type:
'all_routes_deleted'
});
return;
}
// ========================================
// MOVE MAP
// ========================================
case 'move_map': {
ctx.broadcast({
type:
'move_map',
deltaX:
data.deltaX,
deltaY:
data.deltaY
});
return;
}
// ========================================
// ZOOM MAP
// ========================================
case 'zoom_map': {
ctx.broadcast({
type:
'zoom_map',
deltaZoom:
data.deltaZoom
});
return;
}
// ========================================
// ROTATE MAP
// ========================================
//
// Persistimos la rotación en el servidor
// para que cualquier cliente que se conecte
// después reciba el valor real y no arranque
// en 0.
case 'rotate_map': {
const nextRotation =
typeof data.rotation === 'number'
? data.rotation
: 0;
mapRotation = nextRotation;
ctx.broadcast({
type:
'rotate_map',
rotation:
mapRotation,
// Añadimos el playerId que originó el cambio
// para que el emisor pueda ignorar su propio eco.
fromPlayerId:
playerId
});
return;
}
// ========================================
// RESET MAP
// ========================================
case 'reset_map': {
mapRotation = 0;
ctx.broadcast({
type:
'reset_map',
rotation:
mapRotation,
fromPlayerId:
playerId
});
return;
}
// ========================================
// REQUEST MAP CENTER
// ========================================
case 'request_map_center': {
ctx.broadcast({
type:
'request_map_center',
playerId:
playerId
});
return;
}
// ========================================
// MAP CENTER
// ========================================
case 'map_center': {
const targetPlayerId =
data.targetPlayerId ||
playerId;
ctx.sendTo(
targetPlayerId,
{
type:
'map_center',
center:
data.center,
targetPlayerId:
targetPlayerId
}
);
return;
}
// ========================================
// TOGGLE PIN
// ========================================
case 'toggle_pin': {
ctx.broadcast({
type:
'toggle_pin',
visible:
data.visible
});
return;
}
// ========================================
// HIGHLIGHT POINT
// ========================================
case 'highlight_point': {
ctx.broadcast({
type:
'highlight_point',
routeId:
data.routeId,
pointIndex:
data.pointIndex,
highlighted:
data.highlighted
});
return;
}
// ========================================
// ZOOM TO ROUTE
// ========================================
case 'zoom_to_route': {
ctx.broadcast({
type:
'zoom_to_route',
routeId:
data.routeId
});
return;
}
}
} catch (error) {
console.error(
`❌ Error processing message "${data.type}" (caught, server keeps running):`,
error
);
}
},
// ====================================================
// STOP
// ====================================================
async onStop() {
console.log(
'🛑 Maps app stopped'
);
await new Promise(
resolve => {
db.close(
() => resolve()
);
}
);
}
};
}; .\public\mobile.html 60904 caracteres <!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>🕹️ Controller</title>
<meta name="darkreader-lock" content="true" />
<meta name="color-scheme" content="dark" />
<style>
:root {
color-scheme: dark only;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
position: fixed;
overflow: hidden;
background: #0c0e1a;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
touch-action: none !important;
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
overscroll-behavior: none !important;
}
.header {
height: 41px;
padding: 5px 8px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.header .player-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid rgba(167, 139, 250, 0.25);
border-radius: 20px;
background: rgba(167, 139, 250, 0.1);
color: #c4b5fd;
font-size: 13px;
text-align: left;
overflow: hidden;
}
.header .player-btn span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
}
.header .room-btn,
.header .settings-btn,
.header .exit-btn,
.header .lock-btn,
.header .gamepad-indicator {
width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
}
.header .gamepad-indicator {
background: rgba(96, 165, 250, 0.15);
color: #93c5fd;
}
.header .lock-btn {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.header .lock-btn.locked {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.header .room-btn:active,
.header .settings-btn:active,
.header .lock-btn:active {
background: rgba(255, 255, 255, 0.09);
}
.header .header-divider {
color: rgba(255, 255, 255, 0.15);
font-size: 18px;
line-height: 30px;
margin: 0 1px;
}
.header .exit-btn {
display: none;
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
.header .exit-btn.visible {
display: flex;
}
#app-root {
position: absolute;
top: 41px;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
}
.core-menu {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.core-menu-content {
flex: 1;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.core-menu-hint {
padding: 12px 16px;
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
}
.folder-header {
width: 100%;
min-height: 50px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
color: #e0e0e0;
font-size: 16px;
text-align: left;
}
.folder-header:active {
background: rgba(255, 255, 255, 0.08);
}
.app-list {
width: 100%;
display: flex;
flex-direction: column;
}
.app-item {
width: 100%;
min-height: 54px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
color: #e0e0e0;
font-size: 16px;
text-align: left;
}
.app-item:active {
background: rgba(255, 255, 255, 0.08);
}
.app-item.folder {
background: rgba(255, 255, 255, 0.035);
}
.app-item.nested {
padding-left: 36px;
}
.app-item.selected {
padding-left: 13px;
background: rgba(167, 139, 250, 0.14);
border-left: 3px solid #a78bfa;
}
.app-item.nested.selected {
padding-left: 33px;
}
.app-item.wanting {
box-shadow: inset 0 0 0 2px rgba(251, 191, 36, 0.5);
}
.item-check {
width: 22px;
height: 22px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
color: #c4b5fd;
font-size: 14px;
}
.item-check.checked {
background: rgba(167, 139, 250, 0.25);
border-color: rgba(167, 139, 250, 0.55);
}
.item-icon {
width: 28px;
flex-shrink: 0;
text-align: center;
font-size: 20px;
}
.item-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-wants {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.want-badge {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
white-space: nowrap;
}
.folder-arrow {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.25);
font-size: 18px;
}
.empty-folder {
padding: 40px 20px;
color: rgba(255, 255, 255, 0.25);
font-size: 13px;
text-align: center;
}
.core-toolbar {
flex-shrink: 0;
display: flex;
gap: 8px;
padding: 8px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.4);
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.core-toolbar button {
flex: 1;
min-height: 42px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: #e0e0e0;
font-size: 14px;
}
.core-toolbar button:active {
background: rgba(255, 255, 255, 0.1);
}
.core-toolbar button:disabled {
opacity: 0.3;
}
.core-toolbar button.danger {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.room-modal,
.move-modal,
.folder-modal,
.settings-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: none;
align-items: flex-end;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
}
.room-modal.visible,
.move-modal.visible,
.folder-modal.visible,
.settings-modal.visible {
display: flex;
}
.move-modal {
z-index: 1100;
}
.folder-modal {
z-index: 1200;
}
.settings-modal {
z-index: 1300;
}
.room-panel,
.move-panel,
.folder-panel,
.settings-panel {
width: 100%;
max-height: 80%;
overflow: auto;
padding: 18px;
padding-bottom: max(18px, env(safe-area-inset-bottom));
background: #151827;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px 18px 0 0;
}
.room-panel {
max-height: 75%;
}
.room-title,
.move-title,
.settings-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.room-title h2,
.folder-panel h2,
.settings-title h2 {
font-size: 16px;
font-weight: 500;
}
.move-title {
font-size: 16px;
}
.room-close,
.settings-close {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
color: #aaa;
font-size: 16px;
}
.room-section-title {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
}
.room-section-title:first-child {
margin-top: 0;
}
.room-section-empty {
padding: 8px 0;
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
}
.room-separator {
margin: 18px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.player-item {
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.player-item .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-item .actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.player-item .icon-btn {
width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
}
.player-item .icon-btn:active {
background: rgba(255, 255, 255, 0.1);
}
.player-item .icon-btn.main-screen {
background: rgba(96, 165, 250, 0.15);
color: #93c5fd;
}
.player-item .icon-btn.transfer-admin {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.badge {
flex-shrink: 0;
padding: 4px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
}
.admin-badge {
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
}
.tv-badge {
background: rgba(96, 165, 250, 0.1);
color: #93c5fd;
}
.move-option {
width: 100%;
min-height: 48px;
margin-bottom: 6px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
font-size: 14px;
text-align: left;
}
.move-option:active {
background: rgba(255, 255, 255, 0.09);
}
.move-option.cancel {
margin-top: 12px;
justify-content: center;
color: rgba(255, 255, 255, 0.55);
}
.folder-input {
width: 100%;
height: 44px;
padding: 0 12px;
outline: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 15px;
}
.folder-actions,
.settings-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.folder-actions button,
.settings-actions button {
flex: 1;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: #e0e0e0;
}
.settings-actions button.danger {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.settings-section {
margin-bottom: 18px;
}
.settings-section h3 {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 10px;
}
.settings-empty {
color: rgba(255, 255, 255, 0.35);
font-size: 13px;
padding: 8px 0;
}
.settings-hint {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
margin-bottom: 10px;
}
.remap-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.remap-row .label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remap-row .value {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 8px;
background: rgba(167, 139, 250, 0.12);
color: #c4b5fd;
font-size: 12px;
}
.remap-row button {
flex-shrink: 0;
padding: 6px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #e0e0e0;
font-size: 13px;
}
.remap-row button.listening {
background: rgba(251, 191, 36, 0.15);
border-color: rgba(251, 191, 36, 0.4);
color: #fbbf24;
}
.settings-number-input {
width: 70px;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 14px;
text-align: center;
flex-shrink: 0;
}
/* =========================================================
LOCKED OVERLAY (suplantado bloqueado)
========================================================= */
.locked-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(8px);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
color: #e0e0e0;
}
.locked-overlay.visible {
display: flex;
}
.locked-content {
text-align: center;
max-width: 90vw;
}
.locked-title {
font-size: 24px;
margin-bottom: 24px;
color: #c4b5fd;
}
.locked-canvas {
width: 260px;
height: 260px;
margin: 0 auto;
display: block;
border-radius: 12px;
background: #181818;
}
</style>
</head>
<body>
<div class="header">
<button class="player-btn" id="renameBtn">
<span id="playerIcon">🕹️</span>
<span id="playerNameLabel">Player</span>
</button>
<div class="header-actions">
<span
class="gamepad-indicator"
id="gamepadIndicator"
title="Mando conectado"
style="display: none"
>
🎮
</span>
<button
class="lock-btn"
id="lockBtn"
aria-label="Bloquear"
style="display: none"
>
🔒
</button>
<button class="room-btn" id="roomBtn" aria-label="Room">👥</button>
<button class="settings-btn" id="settingsBtn" aria-label="Settings">⚙️</button>
<span class="header-divider">|</span>
<button class="exit-btn" id="exitBtn" aria-label="Close app">✕</button>
</div>
</div>
<div id="app-root"></div>
<div class="room-modal" id="roomModal">
<div class="room-panel">
<div class="room-title">
<h2>Room</h2>
<button class="room-close" id="closeRoomBtn">✕</button>
</div>
<div id="roomPlayersList"></div>
</div>
</div>
<div class="move-modal" id="moveModal">
<div class="move-panel">
<div class="move-title">Mover a...</div>
<div id="moveOptions"></div>
<button class="move-option cancel" id="cancelMoveBtn">Cancelar</button>
</div>
</div>
<div class="folder-modal" id="folderModal">
<div class="folder-panel">
<h2>Nueva carpeta</h2>
<input
class="folder-input"
id="folderNameInput"
type="text"
maxlength="50"
placeholder="Nombre de la carpeta"
autocomplete="off"
/>
<div class="folder-actions">
<button id="cancelFolderBtn">Cancelar</button>
<button id="createFolderBtn">Crear</button>
</div>
</div>
</div>
<div class="folder-modal" id="renameModal">
<div class="folder-panel">
<h2 id="renameTitle">Renombrar</h2>
<input
class="folder-input"
id="renameInput"
type="text"
maxlength="20"
placeholder="Nuevo nombre"
autocomplete="off"
/>
<div class="folder-actions">
<button id="cancelRenameBtn">Cancelar</button>
<button id="confirmRenameBtn">Guardar</button>
</div>
</div>
</div>
<div class="settings-modal" id="settingsModal">
<div class="settings-panel">
<div class="settings-title">
<h2>Ajustes</h2>
<button class="settings-close" id="closeSettingsBtn">✕</button>
</div>
<div id="settingsContent"></div>
</div>
</div>
<div class="locked-overlay" id="lockedOverlay">
<div class="locked-content">
<div class="locked-title">Un momento, por favor...</div>
<canvas id="lockedCanvas" class="locked-canvas" width="260" height="260"></canvas>
</div>
</div>
<script type="module">
import { connectPlatform, mountApp } from "/sdk.js";
document.addEventListener("gesturestart", (event) => {
event.preventDefault();
});
document.addEventListener("gesturechange", (event) => {
event.preventDefault();
});
document.addEventListener("gestureend", (event) => {
event.preventDefault();
});
const savedSessionId =
localStorage.getItem("sessionId") || undefined;
const urlParams = new URLSearchParams(location.search);
const forceAdmin = urlParams.get("admin") === "1";
const suplantarConnectionId = urlParams.get("suplantar") || null;
if (forceAdmin) {
history.replaceState(null, "", "/");
}
const sdk = connectPlatform("mobile", undefined, savedSessionId, {
forceAdmin,
suplantarConnectionId,
});
sdk.enableGamepad();
const appRoot = document.getElementById("app-root");
const exitBtn = document.getElementById("exitBtn");
const headerDivider = document.querySelector(".header-divider");
const roomModal = document.getElementById("roomModal");
const roomPlayersList = document.getElementById("roomPlayersList");
const playerNameLabel = document.getElementById("playerNameLabel");
const playerIcon = document.getElementById("playerIcon");
const moveModal = document.getElementById("moveModal");
const moveOptions = document.getElementById("moveOptions");
const folderModal = document.getElementById("folderModal");
const folderNameInput = document.getElementById("folderNameInput");
const gamepadIndicator = document.getElementById("gamepadIndicator");
const lockBtn = document.getElementById("lockBtn");
const lockedOverlay = document.getElementById("lockedOverlay");
const lockedCanvas = document.getElementById("lockedCanvas");
const settingsModal = document.getElementById("settingsModal");
const settingsContent = document.getElementById("settingsContent");
const renameModal = document.getElementById("renameModal");
const renameInput = document.getElementById("renameInput");
const renameTitle = document.getElementById("renameTitle");
let currentUnmount = null;
let mainScreenId = null;
let currentFolderId = null;
let selectionMode = false;
let selectedItems = new Set();
let renameContext = null;
let listeningAction = null;
let listeningHandler = null;
const connectedGamepads = [];
let wantPressTimer = null;
let currentlyWantingAppId = null;
let lockedMinigame = null;
function getLayout() {
if (sdk.state.appLayout && Array.isArray(sdk.state.appLayout.root)) {
return sdk.state.appLayout;
}
return { version: 1, root: [] };
}
function getAppsMap() {
return new Map((sdk.state.apps || []).map((app) => [app.id, app]));
}
function itemKey(item) {
return item.type + ":" + item.id;
}
function cloneLayout(layout) {
return JSON.parse(JSON.stringify(layout));
}
function generateFolderId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return "folder-" + Date.now() + "-" + Math.random().toString(36).slice(2);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function getMaxNameLength() {
return Math.max(
1,
Number(sdk.state.settings?.maxPlayerNameLength) || 20,
);
}
function findFolder(items, folderId, parent = null) {
for (const item of items) {
if (item.type === "folder" && item.id === folderId) {
return { folder: item, parent };
}
if (item.type === "folder" && Array.isArray(item.children)) {
const result = findFolder(item.children, folderId, item);
if (result) return result;
}
}
return null;
}
function getCurrentItems() {
const layout = getLayout();
if (!currentFolderId) return layout.root;
const result = findFolder(layout.root, currentFolderId);
if (!result) {
currentFolderId = null;
return layout.root;
}
if (!Array.isArray(result.folder.children)) {
result.folder.children = [];
}
return result.folder.children;
}
function findItemContext(items, targetKey, parentItems = items, ancestors = []) {
for (const item of items) {
if (itemKey(item) === targetKey) {
return { item, parentItems, ancestors };
}
if (item.type === "folder" && Array.isArray(item.children)) {
const result = findItemContext(item.children, targetKey, item.children, [
...ancestors,
item,
]);
if (result) return result;
}
}
return null;
}
function folderContainsItem(folder, targetKey) {
if (!folder || folder.type !== "folder") return false;
if (itemKey(folder) === targetKey) return true;
for (const child of folder.children || []) {
if (itemKey(child) === targetKey) return true;
if (child.type === "folder" && folderContainsItem(child, targetKey)) {
return true;
}
}
return false;
}
function saveLayout() {
const layout = cloneLayout(getLayout());
sdk.state.appLayout = layout;
sdk.send("set_app_layout", { layout });
}
function renderPlayerName() {
if (sdk.state.suplantando) {
playerNameLabel.textContent = `--> ${sdk.state.suplantando.name} <--`;
return;
}
const me = sdk.state.players.find((p) => p.id === sdk.state.playerId);
playerNameLabel.textContent = me ? me.name : "Player";
}
function renderPlayerIcon() {
if (!playerIcon) return;
playerIcon.textContent = sdk.state.isAdmin ? "👑" : "🕹️";
}
function renderRoomList() {
const isAdmin = sdk.state.isAdmin;
const myConnectionId = sdk.state.playerId;
const tvs = sdk.state.players.filter((p) => p.role === "tv");
const mobiles = sdk.state.players.filter((p) => p.role === "mobile");
function renderPlayerRow(player) {
let badge = '<span class="badge">Player</span>';
if (player.profileId === sdk.state.adminId) {
badge = '<span class="badge admin-badge">👑 Admin</span>';
} else if (player.role === "tv") {
badge = '<span class="badge tv-badge">📺 TV</span>';
}
const isMainScreen =
player.role === "tv" && player.connectionId === mainScreenId;
let actionsHtml = "";
if (isAdmin) {
const isMe = player.connectionId === myConnectionId;
const renameBtn = !isMe
? `<button class="icon-btn" data-action="rename" data-connection-id="${player.connectionId}" title="Renombrar">✏️</button>`
: "";
const mainScreenBtn =
player.role === "tv" && !isMainScreen
? `<button class="icon-btn main-screen" data-action="set-main" data-connection-id="${player.connectionId}" title="Pantalla principal">🖥️</button>`
: "";
const transferAdminBtn =
player.role === "mobile" &&
!isMe &&
player.profileId !== sdk.state.adminId
? `<button class="icon-btn transfer-admin" data-action="transfer-admin" data-profile-id="${player.profileId}" title="Transferir admin">⭐</button>`
: "";
if (renameBtn || mainScreenBtn || transferAdminBtn) {
actionsHtml = `
<div class="actions">
${transferAdminBtn}
${mainScreenBtn}
${renameBtn}
</div>
`;
}
}
const namePrefix = isMainScreen ? "🖥️ " : "";
return `
<div class="player-item">
<span class="name">${namePrefix}${escapeHtml(player.name)}</span>
${actionsHtml}
${badge}
</div>
`;
}
let html = "";
html += `<div class="room-section-title">📺 TVs</div>`;
if (tvs.length === 0) {
html += `<div class="room-section-empty">No hay TVs conectadas</div>`;
} else {
html += tvs.map(renderPlayerRow).join("");
}
html += `<div class="room-separator"></div>`;
html += `<div class="room-section-title">👥 Jugadores</div>`;
if (mobiles.length === 0) {
html += `<div class="room-section-empty">No hay jugadores conectados</div>`;
} else {
html += mobiles.map(renderPlayerRow).join("");
}
roomPlayersList.innerHTML = html;
if (isAdmin) {
roomPlayersList.querySelectorAll("[data-action]").forEach((btn) => {
btn.onclick = () => {
const action = btn.dataset.action;
const connectionId = btn.dataset.connectionId;
const profileId = btn.dataset.profileId;
if (action === "transfer-admin") {
if (!profileId) return;
sdk.send("transfer_admin", { targetProfileId: profileId });
return;
}
const player = sdk.state.players.find(
(p) => p.connectionId === connectionId,
);
if (!player) return;
if (action === "rename") {
openRenameModal("other", {
connectionId,
currentName: player.name,
});
}
if (action === "set-main") {
sdk.send("set_main_screen", { connectionId });
}
};
});
}
}
function clearWantPress() {
if (wantPressTimer) {
clearTimeout(wantPressTimer);
wantPressTimer = null;
}
}
function clearWanting() {
if (currentlyWantingAppId) {
sdk.send("no_want_app", { appId: currentlyWantingAppId });
currentlyWantingAppId = null;
}
}
function createItemElement(item, appMap) {
const button = document.createElement("button");
button.className = "app-item";
if (item.type === "folder") button.classList.add("folder");
if (currentFolderId) button.classList.add("nested");
if (selectedItems.has(itemKey(item))) {
button.classList.add("selected");
}
if (selectionMode) {
const check = document.createElement("span");
check.className = "item-check";
if (selectedItems.has(itemKey(item))) {
check.classList.add("checked");
check.textContent = "✓";
}
button.appendChild(check);
}
const icon = document.createElement("span");
icon.className = "item-icon";
if (item.type === "folder") {
icon.textContent = "📁";
} else {
const app = appMap.get(item.id);
if (!app) return null;
icon.textContent = app.icon || "🎮";
}
button.appendChild(icon);
const name = document.createElement("span");
name.className = "item-name";
if (item.type === "folder") {
name.textContent = item.name;
} else {
const app = appMap.get(item.id);
name.textContent = app.name;
}
button.appendChild(name);
if (item.type === "app") {
const wants = (sdk.state.appWants || {})[item.id];
if (wants && wants.length > 0) {
const wantsEl = document.createElement("span");
wantsEl.className = "item-wants";
wantsEl.innerHTML = wants
.map((w) => `<span class="want-badge">${escapeHtml(w.name)}</span>`)
.join("");
button.appendChild(wantsEl);
}
}
if (item.type === "folder" && !selectionMode) {
const arrow = document.createElement("span");
arrow.className = "folder-arrow";
arrow.textContent = "›";
button.appendChild(arrow);
}
button.onclick = () => {
if (selectionMode) {
toggleSelection(item);
return;
}
if (item.type === "folder") {
if (!sdk.state.isAdmin) return;
currentFolderId = item.id;
sdk.send("select_folder", { folderId: currentFolderId });
renderMenu();
return;
}
if (!sdk.state.isAdmin) return;
sdk.send("select_app", { appId: item.id });
};
if (item.type === "app" && !sdk.state.isAdmin) {
const startWant = (e) => {
if (e && e.preventDefault) e.preventDefault();
clearWantPress();
wantPressTimer = setTimeout(() => {
wantPressTimer = null;
currentlyWantingAppId = item.id;
button.classList.add("wanting");
sdk.send("want_app", { appId: item.id });
}, 100);
};
const endWant = (e) => {
if (e && e.preventDefault) e.preventDefault();
clearWantPress();
if (currentlyWantingAppId === item.id) {
button.classList.remove("wanting");
sdk.send("no_want_app", { appId: item.id });
currentlyWantingAppId = null;
}
};
button.addEventListener("touchstart", startWant, { passive: false });
button.addEventListener("touchend", endWant, { passive: false });
button.addEventListener("touchcancel", endWant, { passive: false });
button.addEventListener("mousedown", startWant);
button.addEventListener("mouseup", endWant);
button.addEventListener("mouseleave", endWant);
}
return button;
}
function renderMenu() {
if (currentUnmount) {
currentUnmount();
currentUnmount = null;
}
clearWantPress();
exitBtn.classList.remove("visible");
headerDivider.style.display = "none";
const layout = getLayout();
const appMap = getAppsMap();
const items = getCurrentItems();
appRoot.innerHTML = "";
const menu = document.createElement("div");
menu.className = "core-menu";
const content = document.createElement("div");
content.className = "core-menu-content";
if (currentFolderId) {
const folderResult = findFolder(layout.root, currentFolderId);
const header = document.createElement("button");
header.className = "folder-header";
header.textContent =
"‹ " + (folderResult ? folderResult.folder.name : "Atrás");
header.onclick = () => {
if (!sdk.state.isAdmin) return;
if (folderResult && folderResult.parent) {
currentFolderId = folderResult.parent.id;
} else {
currentFolderId = null;
}
sdk.send("select_folder", { folderId: currentFolderId });
selectedItems.clear();
renderMenu();
};
content.appendChild(header);
}
if (selectionMode) {
const hint = document.createElement("div");
hint.className = "core-menu-hint";
hint.textContent =
selectedItems.size > 0
? `${selectedItems.size} seleccionado${selectedItems.size === 1 ? "" : "s"}`
: "Selecciona elementos para mover";
content.appendChild(hint);
}
const list = document.createElement("div");
list.className = "app-list";
for (const item of items) {
const element = createItemElement(item, appMap);
if (element) list.appendChild(element);
}
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-folder";
empty.textContent = "Esta carpeta está vacía";
list.appendChild(empty);
}
content.appendChild(list);
menu.appendChild(content);
const toolbar = document.createElement("div");
toolbar.className = "core-toolbar";
if (!selectionMode) {
if (currentFolderId && sdk.state.isAdmin) {
const deleteFolderButton = document.createElement("button");
deleteFolderButton.className = "danger";
deleteFolderButton.textContent = "Eliminar carpeta";
deleteFolderButton.onclick = deleteCurrentFolder;
toolbar.appendChild(deleteFolderButton);
}
const selectButton = document.createElement("button");
selectButton.textContent = "Seleccionar";
selectButton.onclick = enterSelectionMode;
toolbar.appendChild(selectButton);
if (sdk.state.isAdmin) {
const newFolderButton = document.createElement("button");
newFolderButton.textContent = "Nueva carpeta";
newFolderButton.onclick = openNewFolder;
toolbar.appendChild(newFolderButton);
}
} else {
const moveButton = document.createElement("button");
moveButton.textContent = "Mover";
moveButton.disabled = selectedItems.size === 0;
moveButton.onclick = openMovePicker;
toolbar.appendChild(moveButton);
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancelar";
cancelButton.onclick = exitSelectionMode;
toolbar.appendChild(cancelButton);
}
menu.appendChild(toolbar);
appRoot.appendChild(menu);
}
function enterSelectionMode() {
selectionMode = true;
selectedItems.clear();
renderMenu();
}
function exitSelectionMode() {
selectionMode = false;
selectedItems.clear();
renderMenu();
}
function toggleSelection(item) {
const key = itemKey(item);
if (selectedItems.has(key)) {
selectedItems.delete(key);
} else {
selectedItems.add(key);
}
renderMenu();
}
function getEffectiveSelection() {
const layout = getLayout();
const result = [];
for (const key of selectedItems) {
const context = findItemContext(layout.root, key);
if (!context) continue;
const hasSelectedAncestor = context.ancestors.some((ancestor) =>
selectedItems.has(itemKey(ancestor)),
);
if (!hasSelectedAncestor) {
result.push(context);
}
}
return result;
}
function openNewFolder() {
if (!sdk.state.isAdmin) return;
folderNameInput.value = "";
folderModal.classList.add("visible");
setTimeout(() => folderNameInput.focus(), 50);
}
function closeNewFolder() {
folderModal.classList.remove("visible");
folderNameInput.value = "";
}
function createFolder() {
if (!sdk.state.isAdmin) return;
const name = folderNameInput.value.trim();
if (!name) return;
const items = getCurrentItems();
items.push({
type: "folder",
id: generateFolderId(),
name,
children: [],
});
closeNewFolder();
saveLayout();
renderMenu();
}
function deleteCurrentFolder() {
if (!sdk.state.isAdmin) return;
if (!currentFolderId) return;
const layout = getLayout();
const result = findFolder(layout.root, currentFolderId);
if (!result) return;
const confirmed = window.confirm(
"Todas las apps en el interior pasarán a la carpeta padre. ¿Quieres eliminar esta carpeta?",
);
if (!confirmed) return;
const folder = result.folder;
const parentItems = result.parent ? result.parent.children : layout.root;
const index = parentItems.indexOf(folder);
if (index === -1) return;
const children = Array.isArray(folder.children) ? folder.children : [];
parentItems.splice(index, 1, ...children);
currentFolderId = result.parent ? result.parent.id : null;
sdk.send("select_folder", { folderId: currentFolderId });
selectedItems.clear();
selectionMode = false;
saveLayout();
renderMenu();
}
function openMovePicker() {
if (!sdk.state.isAdmin) return;
const selection = getEffectiveSelection();
if (selection.length === 0) return;
moveOptions.innerHTML = "";
const rootButton = document.createElement("button");
rootButton.className = "move-option";
rootButton.textContent = "⌂ Inicio";
rootButton.onclick = () => moveSelectedTo(null);
moveOptions.appendChild(rootButton);
const layout = getLayout();
function appendFolders(items, depth = 0) {
for (const item of items) {
if (item.type !== "folder") continue;
const invalidDestination = selection.some(
(context) =>
context.item.type === "folder" &&
folderContainsItem(context.item, itemKey(item)),
);
if (!invalidDestination) {
const button = document.createElement("button");
button.className = "move-option";
button.style.paddingLeft = 12 + depth * 20 + "px";
button.textContent = "📁 " + item.name;
button.onclick = () => moveSelectedTo(item.id);
moveOptions.appendChild(button);
}
appendFolders(item.children || [], depth + 1);
}
}
appendFolders(layout.root);
moveModal.classList.add("visible");
}
function closeMovePicker() {
moveModal.classList.remove("visible");
}
function moveSelectedTo(destinationFolderId) {
if (!sdk.state.isAdmin) return;
const layout = getLayout();
const selection = getEffectiveSelection();
if (selection.length === 0) {
closeMovePicker();
return;
}
let destinationItems = layout.root;
if (destinationFolderId) {
const destination = findFolder(layout.root, destinationFolderId);
if (!destination) return;
if (!Array.isArray(destination.folder.children)) {
destination.folder.children = [];
}
destinationItems = destination.folder.children;
}
const movingItems = selection.map((context) => context.item);
for (const context of selection) {
const index = context.parentItems.indexOf(context.item);
if (index !== -1) {
context.parentItems.splice(index, 1);
}
}
destinationItems.push(...movingItems);
closeMovePicker();
selectedItems.clear();
selectionMode = false;
saveLayout();
renderMenu();
}
function openRenameModal(mode, target) {
renameContext = { mode, ...target };
renameInput.value = target.currentName || "";
renameInput.maxLength = getMaxNameLength();
renameTitle.textContent =
mode === "self"
? "Tu nombre"
: `Renombrar "${target.currentName}"`;
renameModal.classList.add("visible");
setTimeout(() => {
renameInput.focus();
renameInput.select();
}, 50);
}
function closeRenameModal() {
renameModal.classList.remove("visible");
renameContext = null;
}
function confirmRename() {
if (!renameContext) return;
const name = renameInput.value.trim();
if (!name) {
closeRenameModal();
return;
}
if (renameContext.mode === "self") {
sdk.send("set_name", { name });
} else {
sdk.send("set_player_name", {
connectionId: renameContext.connectionId,
name,
});
}
closeRenameModal();
}
document.getElementById("cancelRenameBtn").onclick = closeRenameModal;
document.getElementById("confirmRenameBtn").onclick = confirmRename;
renameModal.onclick = (event) => {
if (event.target === renameModal) closeRenameModal();
};
renameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") confirmRename();
if (event.key === "Escape") closeRenameModal();
});
const DEFAULT_ACTIONS = [
{ id: "roll", label: "Tirar / Acción principal" },
{ id: "clear", label: "Limpiar / Acción secundaria" },
{ id: "back", label: "Atrás / Cancelar" },
{ id: "confirm", label: "Confirmar" },
{ id: "up", label: "Arriba" },
{ id: "down", label: "Abajo" },
{ id: "left", label: "Izquierda" },
{ id: "right", label: "Derecha" },
{ id: "menu", label: "Menú" },
];
function getControlLabel(control, index, mapping) {
if (control === "axis") {
const axisLabels = [
"Stick izq. X",
"Stick izq. Y",
"Stick der. X",
"Stick der. Y",
];
return axisLabels[index] || `Eje ${index}`;
}
if (mapping === "standard") {
const buttonLabels = [
"A / Cross",
"B / Circle",
"X / Square",
"Y / Triangle",
"LB / L1",
"RB / R1",
"LT / L2",
"RT / R2",
"Select",
"Start",
"L3",
"R3",
"D-pad arriba",
"D-pad abajo",
"D-pad izq.",
"D-pad der.",
"Home",
];
return buttonLabels[index] || `Botón ${index}`;
}
return `Botón ${index}`;
}
function renderSettingsUI() {
const remap = sdk.loadRemap();
const isAdmin = sdk.state.isAdmin;
let html = "";
if (isAdmin) {
html += `<div class="settings-section">
<h3>Ajustes de la sala</h3>
<div class="remap-row">
<span class="label">Longitud máx. de nombres</span>
<input
type="number"
id="maxNameLengthInput"
class="settings-number-input"
min="1"
max="100"
value="${getMaxNameLength()}"
/>
</div>
</div>`;
}
html += `<div class="settings-section">
<h3>Mandos conectados</h3>`;
if (connectedGamepads.length === 0) {
html += `<div class="settings-empty">
No hay mandos conectados. Empareja uno por Bluetooth y aparecerá aquí.
</div>`;
} else {
connectedGamepads.forEach((gp) => {
html += `<div class="remap-row">
<span class="label">${escapeHtml(gp.name)}</span>
<span class="value">${gp.mapping === "standard" ? "estándar" : "no estándar"}</span>
</div>`;
});
}
html += `</div>`;
html += `<div class="settings-section">
<h3>Asignación de controles</h3>
<div class="settings-hint">
Pulsa "Asignar" y luego el botón o mueve el eje que quieras asociar.
</div>
<div id="remapList"></div>
</div>`;
html += `<div class="settings-actions">
<button id="resetRemapBtn" class="danger">Restablecer</button>
<button id="closeSettingsBtn2">Cerrar</button>
</div>`;
settingsContent.innerHTML = html;
if (isAdmin) {
const input = document.getElementById("maxNameLengthInput");
input.onchange = () => {
const value = parseInt(input.value, 10);
if (Number.isInteger(value) && value >= 1 && value <= 100) {
sdk.send("set_max_name_length", { value });
} else {
input.value = getMaxNameLength();
}
};
}
const remapList = document.getElementById("remapList");
DEFAULT_ACTIONS.forEach((action) => {
const row = document.createElement("div");
row.className = "remap-row";
let assignedControl = null;
for (const [key, actionId] of Object.entries(remap)) {
if (actionId === action.id) {
assignedControl = key;
break;
}
}
let controlLabel = "Sin asignar";
if (assignedControl) {
const [control, idxStr] = assignedControl.split(":");
const idx = parseInt(idxStr, 10);
controlLabel = getControlLabel(control, idx, "standard");
}
const label = document.createElement("span");
label.className = "label";
label.textContent = action.label;
const value = document.createElement("span");
value.className = "value";
value.textContent = controlLabel;
const btn = document.createElement("button");
btn.textContent = "Asignar";
btn.onclick = () => startListening(action.id, btn, value);
row.appendChild(label);
row.appendChild(value);
row.appendChild(btn);
remapList.appendChild(row);
});
document.getElementById("resetRemapBtn").onclick = () => {
sdk.clearRemap();
renderSettingsUI();
};
document.getElementById("closeSettingsBtn2").onclick = closeSettings;
}
function startListening(actionId, btn, valueEl) {
if (listeningAction) return;
listeningAction = actionId;
btn.classList.add("listening");
btn.textContent = "Pulsa...";
valueEl.textContent = "Esperando...";
listeningHandler = (evt) => {
if (evt.control === "connected" || evt.control === "disconnected") return;
const remap = sdk.loadRemap();
const key =
evt.control === "axis" ? `axis:${evt.index}` : `button:${evt.index}`;
remap[key] = actionId;
sdk.saveRemap(remap);
btn.classList.remove("listening");
btn.textContent = "Asignar";
listeningAction = null;
if (listeningHandler) {
sdk.off("gamepad", listeningHandler);
listeningHandler = null;
}
renderSettingsUI();
};
sdk.on("gamepad", listeningHandler);
}
function closeSettings() {
settingsModal.classList.remove("visible");
if (listeningHandler) {
sdk.off("gamepad", listeningHandler);
listeningHandler = null;
}
listeningAction = null;
}
document.getElementById("settingsBtn").onclick = () => {
renderSettingsUI();
settingsModal.classList.add("visible");
};
document.getElementById("closeSettingsBtn").onclick = closeSettings;
settingsModal.onclick = (event) => {
if (event.target === settingsModal) closeSettings();
};
document.getElementById("renameBtn").onclick = () => {
const me = sdk.state.players.find((p) => p.id === sdk.state.playerId);
openRenameModal("self", {
currentName: me?.name || "Player",
});
};
exitBtn.onclick = () => {
sdk.send("exit_app");
};
document.getElementById("roomBtn").onclick = () => {
renderRoomList();
roomModal.classList.add("visible");
};
document.getElementById("closeRoomBtn").onclick = () => {
roomModal.classList.remove("visible");
};
roomModal.onclick = (event) => {
if (event.target === roomModal) {
roomModal.classList.remove("visible");
}
};
document.getElementById("cancelFolderBtn").onclick = closeNewFolder;
document.getElementById("createFolderBtn").onclick = createFolder;
folderModal.onclick = (event) => {
if (event.target === folderModal) closeNewFolder();
};
folderNameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") createFolder();
if (event.key === "Escape") closeNewFolder();
});
document.getElementById("cancelMoveBtn").onclick = closeMovePicker;
moveModal.onclick = (event) => {
if (event.target === moveModal) closeMovePicker();
};
sdk.on("gamepad", (evt) => {
if (evt.control === "connected") {
if (!connectedGamepads.find((g) => g.id === evt.gamepadId)) {
connectedGamepads.push({
id: evt.gamepadId,
name: evt.gamepadName,
mapping: evt.mapping,
});
}
gamepadIndicator.style.display = "flex";
} else if (evt.control === "disconnected") {
const idx = connectedGamepads.findIndex((g) => g.id === evt.gamepadId);
if (idx !== -1) connectedGamepads.splice(idx, 1);
if (connectedGamepads.length === 0) {
gamepadIndicator.style.display = "none";
}
}
if (settingsModal.classList.contains("visible")) {
renderSettingsUI();
}
});
function updateSensorsForCurrentApp() {
const app = sdk.state.apps.find(
(item) => item.id === sdk.state.currentAppId,
);
if (app?.sensors?.mobile) {
try {
sdk.enableSensors();
} catch (err) {
console.warn("[mobile] No se pudieron activar sensores:", err);
}
} else {
sdk.disableSensors();
}
}
function updateLockBtn() {
if (!sdk.state.suplantando) {
lockBtn.style.display = "none";
return;
}
lockBtn.style.display = "flex";
const isLocked = sdk.state.lockedPlayers.has(sdk.state.profileId);
lockBtn.classList.toggle("locked", isLocked);
lockBtn.textContent = isLocked ? "🔓" : "🔒";
lockBtn.title = isLocked ? "Desbloquear" : "Bloquear";
}
lockBtn.onclick = () => {
if (!sdk.state.suplantando) return;
const isLocked = sdk.state.lockedPlayers.has(sdk.state.profileId);
sdk.send(isLocked ? "unlock_player" : "lock_player");
};
function startLockedMinigame() {
if (lockedMinigame) return;
const ctx = lockedCanvas.getContext("2d");
let dot = { x: 130, y: 130, r: 20, vx: 2, vy: 1.5 };
let score = 0;
let running = true;
function frame() {
if (!running) return;
ctx.fillStyle = "#181818";
ctx.fillRect(0, 0, 260, 260);
dot.x += dot.vx;
dot.y += dot.vy;
if (dot.x < dot.r || dot.x > 260 - dot.r) dot.vx *= -1;
if (dot.y < dot.r || dot.y > 260 - dot.r) dot.vy *= -1;
ctx.beginPath();
ctx.arc(dot.x, dot.y, dot.r, 0, Math.PI * 2);
ctx.fillStyle = "#a78bfa";
ctx.fill();
ctx.fillStyle = "#e0e0e0";
ctx.font = "14px sans-serif";
ctx.fillText("Score: " + score, 10, 20);
requestAnimationFrame(frame);
}
const onTouch = (e) => {
e.preventDefault();
const rect = lockedCanvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
const x = t.clientX - rect.left;
const y = t.clientY - rect.top;
const dx = x - dot.x;
const dy = y - dot.y;
if (Math.sqrt(dx * dx + dy * dy) <= dot.r) {
score++;
dot.x = Math.random() * 200 + 30;
dot.y = Math.random() * 200 + 30;
dot.vx = (Math.random() - 0.5) * 4;
dot.vy = (Math.random() - 0.5) * 4;
}
};
lockedCanvas.addEventListener("touchstart", onTouch, { passive: false });
lockedCanvas.addEventListener("mousedown", onTouch);
frame();
lockedMinigame = {
stop: () => {
running = false;
lockedCanvas.removeEventListener("touchstart", onTouch);
lockedCanvas.removeEventListener("mousedown", onTouch);
},
};
}
function stopLockedMinigame() {
if (lockedMinigame) {
lockedMinigame.stop();
lockedMinigame = null;
}
}
function updateLockedOverlay() {
// El overlay solo debe mostrarse en el jugador original,
// no en el suplantador. El suplantador comparte profileId,
// así que se distingue por sdk.state.suplantando.
const isLocked =
!sdk.state.suplantando &&
sdk.state.lockedPlayers.has(sdk.state.profileId);
lockedOverlay.classList.toggle("visible", isLocked);
if (isLocked) {
startLockedMinigame();
} else {
stopLockedMinigame();
}
}
sdk.on("init", (data) => {
mainScreenId = sdk.state.mainScreenId;
if (sdk.state.sessionId) {
localStorage.setItem("sessionId", sdk.state.sessionId);
}
currentFolderId =
data && typeof data.folderId === "string" ? data.folderId : null;
selectionMode = false;
selectedItems.clear();
renderPlayerName();
renderPlayerIcon();
updateSensorsForCurrentApp();
updateLockBtn();
updateLockedOverlay();
renderApp();
});
sdk.on("main_screen_changed", ({ mainScreenId: id }) => {
mainScreenId = id;
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
});
sdk.on("admin_changed", () => {
exitBtn.classList.toggle(
"visible",
sdk.state.isAdmin && !!sdk.state.currentAppId,
);
headerDivider.style.display =
sdk.state.isAdmin && !!sdk.state.currentAppId ? "inline" : "none";
renderPlayerIcon();
updateLockBtn();
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("app_selected", () => {
currentFolderId = null;
selectionMode = false;
selectedItems.clear();
updateSensorsForCurrentApp();
renderApp();
});
sdk.on("app_layout", () => {
if (
currentFolderId &&
!findFolder(sdk.state.appLayout.root, currentFolderId)
) {
currentFolderId = null;
}
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("apps_list", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("folder_selected", (data) => {
currentFolderId =
data && typeof data.folderId === "string" ? data.folderId : null;
selectionMode = false;
selectedItems.clear();
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("players_update", () => {
renderPlayerName();
renderPlayerIcon();
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
});
sdk.on("player_joined", () => {
renderPlayerName();
renderPlayerIcon();
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
});
sdk.on("player_left", () => {
renderPlayerName();
renderPlayerIcon();
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
});
sdk.on("player_renamed", () => {
if (roomModal.classList.contains("visible")) {
renderRoomList();
}
});
sdk.on("settings_changed", () => {
if (renameModal.classList.contains("visible")) {
renameInput.maxLength = getMaxNameLength();
}
if (settingsModal.classList.contains("visible")) {
renderSettingsUI();
}
});
sdk.on("apps_wants_changed", () => {
if (!sdk.state.currentAppId) {
renderMenu();
}
});
sdk.on("locked_changed", () => {
updateLockBtn();
updateLockedOverlay();
});
async function renderApp() {
if (currentUnmount) {
currentUnmount();
currentUnmount = null;
}
const app = sdk.state.apps.find(
(item) => item.id === sdk.state.currentAppId,
);
if (!app) {
selectionMode = false;
selectedItems.clear();
renderMenu();
return;
}
const appIsOpen = !!sdk.state.currentAppId;
exitBtn.classList.toggle("visible", appIsOpen && sdk.state.isAdmin);
headerDivider.style.display =
appIsOpen && sdk.state.isAdmin ? "inline" : "none";
currentUnmount = await mountApp(appRoot, app, "mobile", sdk);
}
renderPlayerName();
renderPlayerIcon();
if (sdk.state.apps.length > 0) {
renderApp();
} else {
renderMenu();
}
</script>
</body>
</html>