Tu Nombre commited on
Commit
97c7a8c
·
1 Parent(s): f01295f

Actualización: Cambio a modelo Llama 3.1 8B

Browse files
Files changed (3) hide show
  1. familiar.html +293 -383
  2. index.html +697 -630
  3. index_peer.js +25 -0
familiar.html CHANGED
@@ -1,321 +1,172 @@
1
  <!DOCTYPE html>
2
  <html lang="es">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Monitor Familiar</title>
7
- <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@300;600;800&display=swap" rel="stylesheet">
8
- <style>
9
- :root {
10
- --bg: #030a0e;
11
- --panel: #0a1a22;
12
- --border: #0e3a50;
13
- --cyan: #00e5ff;
14
- --green: #00ff88;
15
- --red: #ff2244;
16
- --yellow: #ffcc00;
17
- --text: #c8e8f0;
18
- --mono: 'Share Tech Mono', monospace;
19
- --display: 'Exo 2', sans-serif;
20
- }
21
-
22
- * { margin: 0; padding: 0; box-sizing: border-box; }
23
-
24
- body {
25
- background: var(--bg);
26
- color: var(--text);
27
- font-family: var(--display);
28
- min-height: 100vh;
29
- }
30
-
31
- body::before {
32
- content: '';
33
- position: fixed;
34
- inset: 0;
35
- background: radial-gradient(ellipse at 20% 50%, rgba(0,229,255,0.04) 0%, transparent 60%);
36
- pointer-events: none;
37
- z-index: 0;
38
- }
39
-
40
- .grid-bg {
41
- position: fixed;
42
- inset: 0;
43
- background-image:
44
- linear-gradient(rgba(0,229,255,0.04) 1px, transparent 1px),
45
- linear-gradient(90deg, rgba(0,229,255,0.04) 1px, transparent 1px);
46
- background-size: 40px 40px;
47
- pointer-events: none;
48
- z-index: 0;
49
- }
50
-
51
- .container {
52
- position: relative;
53
- z-index: 1;
54
- max-width: 480px;
55
- margin: 0 auto;
56
- padding: 16px;
57
- }
58
-
59
- .header {
60
- text-align: center;
61
- padding: 20px 0 16px;
62
- border-bottom: 1px solid var(--border);
63
- margin-bottom: 16px;
64
- }
65
-
66
- .header h1 {
67
- font-size: 1.4rem;
68
- font-weight: 800;
69
- letter-spacing: 0.15em;
70
- color: var(--cyan);
71
- text-shadow: 0 0 20px rgba(0,229,255,0.5);
72
- text-transform: uppercase;
73
- }
74
-
75
- .header p {
76
- font-family: var(--mono);
77
- font-size: 0.65rem;
78
- color: #4a7a8a;
79
- margin-top: 4px;
80
- letter-spacing: 0.1em;
81
- }
82
-
83
- /* Nombre paciente */
84
- .nombre-paciente {
85
- text-align: center;
86
- font-size: 1.1rem;
87
- font-weight: 600;
88
- color: var(--cyan);
89
- margin-bottom: 14px;
90
- font-family: var(--mono);
91
- letter-spacing: 0.1em;
92
- }
93
-
94
- /* Estado principal */
95
- .estado-box {
96
- border: 2px solid var(--border);
97
- border-radius: 8px;
98
- padding: 20px;
99
- margin-bottom: 14px;
100
- background: var(--panel);
101
- text-align: center;
102
- transition: all 0.3s;
103
- }
104
-
105
- .estado-box.normal { border-color: var(--green); box-shadow: 0 0 20px rgba(0,255,136,0.15); }
106
- .estado-box.critico { border-color: var(--red); box-shadow: 0 0 40px rgba(255,34,68,0.4); animation: pulseRed 0.8s infinite; }
107
- .estado-box.sin_datos { border-color: var(--border); }
108
-
109
- @keyframes pulseRed {
110
- 0%, 100% { box-shadow: 0 0 20px rgba(255,34,68,0.3); }
111
- 50% { box-shadow: 0 0 60px rgba(255,34,68,0.7); }
112
- }
113
-
114
- .estado-label {
115
- font-family: var(--mono);
116
- font-size: 0.6rem;
117
- letter-spacing: 0.2em;
118
- color: #4a7a8a;
119
- margin-bottom: 8px;
120
- }
121
-
122
- .estado-valor {
123
- font-size: 2.2rem;
124
- font-weight: 800;
125
- letter-spacing: 0.1em;
126
- text-transform: uppercase;
127
- }
128
-
129
- .estado-valor.normal { color: var(--green); text-shadow: 0 0 15px rgba(0,255,136,0.5); }
130
- .estado-valor.critico { color: var(--red); text-shadow: 0 0 20px rgba(255,34,68,0.8); }
131
- .estado-valor.sin_datos { color: #4a7a8a; }
132
-
133
- .motivo-texto {
134
- font-family: var(--mono);
135
- font-size: 0.78rem;
136
- color: var(--yellow);
137
- margin-top: 10px;
138
- min-height: 18px;
139
- line-height: 1.4;
140
- }
141
-
142
- /* Constantes */
143
- .constantes {
144
- display: grid;
145
- grid-template-columns: 1fr 1fr;
146
- gap: 10px;
147
- margin-bottom: 14px;
148
- }
149
-
150
- .constante-card {
151
- background: var(--panel);
152
- border: 1px solid var(--border);
153
- border-radius: 8px;
154
- padding: 14px;
155
- text-align: center;
156
- }
157
-
158
- .constante-label {
159
- font-family: var(--mono);
160
- font-size: 0.55rem;
161
- color: #4a7a8a;
162
- letter-spacing: 0.15em;
163
- text-transform: uppercase;
164
- margin-bottom: 6px;
165
- }
166
-
167
- .constante-valor {
168
- font-family: var(--mono);
169
- font-size: 2rem;
170
- font-weight: bold;
171
- color: var(--cyan);
172
- }
173
-
174
- .constante-unidad {
175
- font-size: 0.65rem;
176
- color: #4a7a8a;
177
- margin-left: 2px;
178
- }
179
-
180
- /* Sonido */
181
- .sonido-card {
182
- background: var(--panel);
183
- border: 1px solid var(--border);
184
- border-radius: 8px;
185
- padding: 14px;
186
- margin-bottom: 14px;
187
- }
188
-
189
- .sonido-label {
190
- font-family: var(--mono);
191
- font-size: 0.55rem;
192
- color: #4a7a8a;
193
- letter-spacing: 0.15em;
194
- text-transform: uppercase;
195
- margin-bottom: 8px;
196
- }
197
-
198
- .sonido-texto {
199
- font-family: var(--mono);
200
- font-size: 0.8rem;
201
- color: var(--text);
202
- }
203
-
204
- /* Actualización */
205
- .actualizacion {
206
- text-align: center;
207
- font-family: var(--mono);
208
- font-size: 0.65rem;
209
- color: #4a7a8a;
210
- margin-bottom: 14px;
211
- }
212
-
213
- .punto-vivo {
214
- display: inline-block;
215
- width: 6px;
216
- height: 6px;
217
- background: var(--green);
218
- border-radius: 50%;
219
- margin-right: 6px;
220
- animation: blink 1.5s infinite;
221
- vertical-align: middle;
222
- }
223
-
224
- @keyframes blink {
225
- 0%, 100% { opacity: 1; }
226
- 50% { opacity: 0.2; }
227
- }
228
-
229
- /* Botón ir a vigilante */
230
- .btn-vigilante {
231
- display: block;
232
- width: 100%;
233
- padding: 14px;
234
- background: transparent;
235
- border: 1px solid var(--border);
236
- border-radius: 8px;
237
- color: #4a7a8a;
238
- font-family: var(--display);
239
- font-size: 0.8rem;
240
- font-weight: 600;
241
- letter-spacing: 0.1em;
242
- text-transform: uppercase;
243
- cursor: pointer;
244
- text-align: center;
245
- text-decoration: none;
246
- margin-bottom: 14px;
247
- transition: all 0.2s;
248
- }
249
-
250
- .btn-vigilante:hover { border-color: var(--cyan); color: var(--cyan); }
251
-
252
- /* Alarma overlay */
253
- .alarma-overlay {
254
- display: none;
255
- position: fixed;
256
- inset: 0;
257
- background: rgba(255,34,68,0.15);
258
- z-index: 100;
259
- backdrop-filter: blur(2px);
260
- animation: flashRed 0.5s infinite;
261
- align-items: center;
262
- justify-content: center;
263
- }
264
-
265
- .alarma-overlay.activa { display: flex; }
266
-
267
- @keyframes flashRed {
268
- 0%, 100% { background: rgba(255,34,68,0.1); }
269
- 50% { background: rgba(255,34,68,0.3); }
270
- }
271
-
272
- .alarma-card {
273
- background: #1a0508;
274
- border: 2px solid var(--red);
275
- border-radius: 12px;
276
- padding: 30px;
277
- text-align: center;
278
- max-width: 300px;
279
- box-shadow: 0 0 60px rgba(255,34,68,0.5);
280
- }
281
-
282
- .alarma-icono { font-size: 3rem; margin-bottom: 10px; }
283
- .alarma-titulo {
284
- font-size: 1.5rem;
285
- font-weight: 800;
286
- color: var(--red);
287
- letter-spacing: 0.15em;
288
- text-transform: uppercase;
289
- margin-bottom: 10px;
290
- }
291
-
292
- .alarma-motivo {
293
- font-family: var(--mono);
294
- font-size: 0.8rem;
295
- color: var(--text);
296
- margin-bottom: 20px;
297
- line-height: 1.5;
298
- }
299
-
300
- .btn-silenciar {
301
- background: var(--red);
302
- color: white;
303
- border: none;
304
- border-radius: 6px;
305
- padding: 12px 24px;
306
- font-family: var(--display);
307
- font-weight: 600;
308
- font-size: 0.85rem;
309
- cursor: pointer;
310
- letter-spacing: 0.1em;
311
- text-transform: uppercase;
312
- }
313
- </style>
314
- <link rel="manifest" href="/manifest.json">
315
- <script>if("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js");</script>
316
  </head>
317
  <body>
318
-
319
  <div class="grid-bg"></div>
320
 
321
  <div class="alarma-overlay" id="alarmaOverlay">
@@ -328,16 +179,38 @@
328
  </div>
329
 
330
  <div class="container">
331
-
332
  <div class="header">
333
  <h1>👨‍👩‍👧 Monitor Familiar</h1>
334
  <p>VISTA DEL CUIDADOR · ACTUALIZACIÓN EN TIEMPO REAL</p>
335
  </div>
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  <div class="nombre-paciente" id="nombrePaciente">── Esperando datos ──</div>
338
 
339
  <div class="estado-box sin_datos" id="estadoBox">
340
- <div class="estado-label">// ESTADO DEL PACIENTE</div>
341
  <div class="estado-valor sin_datos" id="estadoValor">SIN DATOS</div>
342
  <div class="motivo-texto" id="motivoTexto"></div>
343
  </div>
@@ -364,7 +237,6 @@
364
  </div>
365
 
366
  <a href="/app" class="btn-vigilante">⚕ Ir a pantalla de vigilancia</a>
367
-
368
  </div>
369
 
370
  <script>
@@ -372,89 +244,128 @@
372
  let alarmaActiva = false;
373
  let ultimoEstado = "";
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  async function actualizarDatos() {
376
  try {
377
  const resp = await fetch(BACKEND);
378
  const data = await resp.json();
379
 
380
- // Nombre
381
- document.getElementById('nombrePaciente').textContent =
382
- '👤 ' + (data.nombre || 'Paciente');
 
 
383
 
384
- // Pulso y oxígeno
385
- document.getElementById('pulsoValor').innerHTML =
386
- (data.pulso || '--') + '<span class="constante-unidad">BPM</span>';
387
- document.getElementById('oxigenoValor').innerHTML =
388
- (data.oxigeno || '--') + '<span class="constante-unidad">%</span>';
389
-
390
- // Sonido
391
- document.getElementById('sonidoTexto').textContent =
392
- data.sonido || 'Sin datos';
393
-
394
- // Última actualización
395
- document.getElementById('ultimaActualizacion').textContent =
396
- 'Última actualización: ' + (data.ultima_actualizacion || '--');
397
-
398
- // Estado
399
  const estado = (data.estado || 'SIN_DATOS').toUpperCase();
400
- const box = document.getElementById('estadoBox');
401
- const valor = document.getElementById('estadoValor');
402
  const motivo = document.getElementById('motivoTexto');
403
 
404
- if (estado === 'CRITICO') {
405
- box.className = 'estado-box critico';
406
- valor.className = 'estado-valor critico';
407
- valor.textContent = '⚠ CRÍTICO';
408
- motivo.textContent = data.motivo || '';
409
-
410
- // Disparar alarma solo si es nueva
411
- if (ultimoEstado !== 'CRITICO' && !alarmaActiva) {
412
- dispararAlarma(data.motivo || 'Estado crítico detectado');
413
- }
414
- } else if (estado === 'NORMAL') {
415
- box.className = 'estado-box normal';
416
- valor.className = 'estado-valor normal';
417
- valor.textContent = '✓ NORMAL';
418
- motivo.textContent = '';
419
- } else {
420
- box.className = 'estado-box sin_datos';
421
- valor.className = 'estado-valor sin_datos';
422
- valor.textContent = 'SIN DATOS';
423
- motivo.textContent = 'Inicia la vigilancia en la otra pantalla';
424
- }
425
 
 
426
  ultimoEstado = estado;
427
 
428
- } catch (err) {
429
- document.getElementById('ultimaActualizacion').textContent =
430
- 'Error de conexión — reintentando...';
431
  }
432
  }
433
 
434
- function dispararAlarma(motivo) {
435
  alarmaActiva = true;
436
  document.getElementById('alarmaMotivo').textContent = motivo;
437
  document.getElementById('alarmaOverlay').classList.add('activa');
438
-
439
- try {
440
- const ctx = new AudioContext();
441
- function beep(freq, start, dur) {
442
- const osc = ctx.createOscillator();
443
- const gain = ctx.createGain();
444
- osc.connect(gain);
445
- gain.connect(ctx.destination);
446
- osc.frequency.value = freq;
447
- osc.type = 'square';
448
- gain.gain.setValueAtTime(0.3, ctx.currentTime + start);
449
- gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + start + dur);
450
- osc.start(ctx.currentTime + start);
451
- osc.stop(ctx.currentTime + start + dur);
452
- }
453
- for (let i = 0; i < 8; i++) {
454
- beep(880, i * 0.4, 0.3);
455
- beep(660, i * 0.4 + 0.2, 0.15);
456
- }
457
- } catch(e) {}
458
  }
459
 
460
  function silenciarAlarma() {
@@ -462,9 +373,8 @@
462
  document.getElementById('alarmaOverlay').classList.remove('activa');
463
  }
464
 
465
- // Actualizar cada 5 segundos
466
  actualizarDatos();
467
- setInterval(actualizarDatos, 5000);
468
  </script>
469
  </body>
470
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="es">
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Monitor Familiar</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@300;600;800&display=swap" rel="stylesheet">
8
+ <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
9
+ <style>
10
+ :root {
11
+ --bg: #030a0e;
12
+ --panel: #0a1a22;
13
+ --border: #0e3a50;
14
+ --cyan: #00e5ff;
15
+ --green: #00ff88;
16
+ --red: #ff2244;
17
+ --yellow: #ffcc00;
18
+ --text: #c8e8f0;
19
+ --mono: 'Share Tech Mono', monospace;
20
+ --display: 'Exo 2', sans-serif;
21
+ }
22
+ * { margin: 0; padding: 0; box-sizing: border-box; }
23
+ body { background: var(--bg); color: var(--text); font-family: var(--display); min-height: 100vh; }
24
+ body::before {
25
+ content: ''; position: fixed; inset: 0;
26
+ background: radial-gradient(ellipse at 20% 50%, rgba(0,229,255,0.04) 0%, transparent 60%);
27
+ pointer-events: none; z-index: 0;
28
+ }
29
+ .grid-bg {
30
+ position: fixed; inset: 0;
31
+ background-image:
32
+ linear-gradient(rgba(0,229,255,0.04) 1px, transparent 1px),
33
+ linear-gradient(90deg, rgba(0,229,255,0.04) 1px, transparent 1px);
34
+ background-size: 40px 40px;
35
+ pointer-events: none; z-index: 0;
36
+ }
37
+ .container { position: relative; z-index: 1; max-width: 480px; margin: 0 auto; padding: 16px; }
38
+ .header { text-align: center; padding: 20px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
39
+ .header h1 { font-size: 1.4rem; font-weight: 800; letter-spacing: 0.15em; color: var(--cyan); text-shadow: 0 0 20px rgba(0,229,255,0.5); text-transform: uppercase; }
40
+ .header p { font-family: var(--mono); font-size: 0.65rem; color: #4a7a8a; margin-top: 4px; letter-spacing: 0.1em; }
41
+
42
+ /* ── PEER CONNECT BOX ── */
43
+ .peer-connect-box {
44
+ background: var(--panel);
45
+ border: 1px solid var(--border);
46
+ border-radius: 8px;
47
+ padding: 14px;
48
+ margin-bottom: 14px;
49
+ }
50
+ .peer-connect-label {
51
+ font-family: var(--mono);
52
+ font-size: 0.55rem;
53
+ color: #4a7a8a;
54
+ letter-spacing: 0.2em;
55
+ text-transform: uppercase;
56
+ margin-bottom: 8px;
57
+ }
58
+ .peer-input-row { display: flex; gap: 8px; }
59
+ .peer-input {
60
+ flex: 1;
61
+ background: var(--bg);
62
+ border: 1px solid var(--border);
63
+ border-radius: 6px;
64
+ padding: 9px 12px;
65
+ color: var(--cyan);
66
+ font-family: var(--mono);
67
+ font-size: 0.75rem;
68
+ outline: none;
69
+ transition: border-color 0.2s;
70
+ }
71
+ .peer-input:focus { border-color: var(--cyan); }
72
+ .peer-input::placeholder { color: #2a4a5a; }
73
+ .btn-conectar-cam {
74
+ padding: 9px 16px;
75
+ background: var(--cyan);
76
+ color: var(--bg);
77
+ border: none;
78
+ border-radius: 6px;
79
+ font-family: var(--display);
80
+ font-size: 0.75rem;
81
+ font-weight: 700;
82
+ letter-spacing: 0.08em;
83
+ text-transform: uppercase;
84
+ cursor: pointer;
85
+ transition: background 0.2s;
86
+ white-space: nowrap;
87
+ }
88
+ .btn-conectar-cam:hover { background: #00ccee; }
89
+ .btn-conectar-cam:disabled { background: #0a3040; color: #2a5060; cursor: not-allowed; }
90
+ .peer-estado-cam {
91
+ margin-top: 8px;
92
+ font-family: var(--mono);
93
+ font-size: 0.65rem;
94
+ color: #4a7a8a;
95
+ letter-spacing: 0.08em;
96
+ }
97
+ .peer-estado-cam.conectado { color: var(--green); }
98
+ .peer-estado-cam.error { color: var(--red); }
99
+
100
+ /* Cámara remota */
101
+ .camara-container {
102
+ border: 1px solid var(--border);
103
+ border-radius: 8px;
104
+ overflow: hidden;
105
+ margin-bottom: 14px;
106
+ position: relative;
107
+ background: #000;
108
+ aspect-ratio: 4/3;
109
+ display: none;
110
+ }
111
+ .camara-container.visible { display: block; }
112
+ #videoRemoto { width: 100%; height: 100%; object-fit: cover; display: block; }
113
+ .camara-overlay { position: absolute; inset: 0; pointer-events: none; }
114
+ .corner { position: absolute; width: 20px; height: 20px; border-color: var(--cyan); border-style: solid; opacity: 0.7; }
115
+ .corner.tl { top: 8px; left: 8px; border-width: 2px 0 0 2px; }
116
+ .corner.tr { top: 8px; right: 8px; border-width: 2px 2px 0 0; }
117
+ .corner.bl { bottom: 8px; left: 8px; border-width: 0 0 2px 2px; }
118
+ .corner.br { bottom: 8px; right: 8px; border-width: 0 2px 2px 0; }
119
+ .camara-label { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 0.6rem; color: var(--cyan); letter-spacing: 0.15em; opacity: 0.8; }
120
+ .live-dot { position: absolute; top: 10px; right: 32px; width: 8px; height: 8px; background: var(--red); border-radius: 50%; animation: blink 1.2s infinite; }
121
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
122
+
123
+ /* Nombre paciente */
124
+ .nombre-paciente { text-align: center; font-size: 1.1rem; font-weight: 600; color: var(--cyan); margin-bottom: 14px; font-family: var(--mono); letter-spacing: 0.1em; }
125
+ /* Estado */
126
+ .estado-box { border: 2px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 14px; background: var(--panel); text-align: center; transition: all 0.3s; }
127
+ .estado-box.normal { border-color: var(--green); box-shadow: 0 0 20px rgba(0,255,136,0.15); }
128
+ .estado-box.critico { border-color: var(--red); box-shadow: 0 0 40px rgba(255,34,68,0.4); animation: pulseRed 0.8s infinite; }
129
+ .estado-box.sin_datos { border-color: var(--border); }
130
+ @keyframes pulseRed { 0%, 100% { box-shadow: 0 0 20px rgba(255,34,68,0.3); } 50% { box-shadow: 0 0 60px rgba(255,34,68,0.7); } }
131
+ .estado-label { font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.2em; color: #4a7a8a; margin-bottom: 8px; }
132
+ .estado-valor { font-size: 2.2rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
133
+ .estado-valor.normal { color: var(--green); text-shadow: 0 0 15px rgba(0,255,136,0.5); }
134
+ .estado-valor.critico { color: var(--red); text-shadow: 0 0 20px rgba(255,34,68,0.8); }
135
+ .estado-valor.sin_datos { color: #4a7a8a; }
136
+ .motivo-texto { font-family: var(--mono); font-size: 0.78rem; color: var(--yellow); margin-top: 10px; min-height: 18px; line-height: 1.4; }
137
+ /* Constantes */
138
+ .constantes { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
139
+ .constante-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
140
+ .constante-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 6px; }
141
+ .constante-valor { font-family: var(--mono); font-size: 2rem; font-weight: bold; color: var(--cyan); }
142
+ .constante-unidad { font-size: 0.65rem; color: #4a7a8a; margin-left: 2px; }
143
+ /* Sonido */
144
+ .sonido-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 14px; }
145
+ .sonido-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
146
+ .sonido-texto { font-family: var(--mono); font-size: 0.8rem; color: var(--text); }
147
+ /* Actualización */
148
+ .actualizacion { text-align: center; font-family: var(--mono); font-size: 0.65rem; color: #4a7a8a; margin-bottom: 14px; }
149
+ .punto-vivo { display: inline-block; width: 6px; height: 6px; background: var(--green); border-radius: 50%; margin-right: 6px; animation: blink 1.5s infinite; vertical-align: middle; }
150
+ /* Botón */
151
+ .btn-vigilante { display: block; width: 100%; padding: 14px; background: transparent; border: 1px solid var(--border); border-radius: 8px; color: #4a7a8a; font-family: var(--display); font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; text-align: center; text-decoration: none; margin-bottom: 14px; transition: all 0.2s; }
152
+ .btn-vigilante:hover { border-color: var(--cyan); color: var(--cyan); }
153
+ /* Alarma */
154
+ .alarma-overlay { display: none; position: fixed; inset: 0; background: rgba(255,34,68,0.15); z-index: 100; backdrop-filter: blur(2px); animation: flashRed 0.5s infinite; align-items: center; justify-content: center; }
155
+ .alarma-overlay.activa { display: flex; }
156
+ @keyframes flashRed { 0%, 100% { background: rgba(255,34,68,0.1); } 50% { background: rgba(255,34,68,0.3); } }
157
+ .alarma-card { background: #1a0508; border: 2px solid var(--red); border-radius: 12px; padding: 30px; text-align: center; max-width: 300px; box-shadow: 0 0 60px rgba(255,34,68,0.5); }
158
+ .alarma-icono { font-size: 3rem; margin-bottom: 10px; }
159
+ .alarma-titulo { font-size: 1.5rem; font-weight: 800; color: var(--red); letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 10px; }
160
+ .alarma-motivo { font-family: var(--mono); font-size: 0.8rem; color: var(--text); margin-bottom: 20px; line-height: 1.5; }
161
+ .btn-silenciar { background: var(--red); color: white; border: none; border-radius: 6px; padding: 12px 24px; font-family: var(--display); font-weight: 600; font-size: 0.85rem; cursor: pointer; letter-spacing: 0.1em; text-transform: uppercase; }
162
+ ::-webkit-scrollbar { width: 4px; }
163
+ ::-webkit-scrollbar-track { background: var(--bg); }
164
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
165
+ </style>
166
+ <link rel="manifest" href="/manifest.json">
167
+ <script>if("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js");</script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  </head>
169
  <body>
 
170
  <div class="grid-bg"></div>
171
 
172
  <div class="alarma-overlay" id="alarmaOverlay">
 
179
  </div>
180
 
181
  <div class="container">
 
182
  <div class="header">
183
  <h1>👨‍👩‍👧 Monitor Familiar</h1>
184
  <p>VISTA DEL CUIDADOR · ACTUALIZACIÓN EN TIEMPO REAL</p>
185
  </div>
186
 
187
+ <!-- PEER CONNECT -->
188
+ <div class="peer-connect-box">
189
+ <div class="peer-connect-label">// Cámara en vivo del paciente</div>
190
+ <div class="peer-input-row">
191
+ <input class="peer-input" id="peerIdInput" type="text" placeholder="Pega el ID de la cámara...">
192
+ <button class="btn-conectar-cam" id="btnConectar" onclick="conectarCamara()">VER</button>
193
+ </div>
194
+ <div class="peer-estado-cam" id="peerEstadoCam">Introduce el ID que aparece en la pantalla del vigilante</div>
195
+ </div>
196
+
197
+ <!-- VIDEO REMOTO -->
198
+ <div class="camara-container" id="camaraContainer">
199
+ <video id="videoRemoto" autoplay playsinline></video>
200
+ <div class="camara-overlay">
201
+ <div class="corner tl"></div>
202
+ <div class="corner tr"></div>
203
+ <div class="corner bl"></div>
204
+ <div class="corner br"></div>
205
+ <div class="camara-label">EN VIVO — PACIENTE</div>
206
+ <div class="live-dot"></div>
207
+ </div>
208
+ </div>
209
+
210
  <div class="nombre-paciente" id="nombrePaciente">── Esperando datos ──</div>
211
 
212
  <div class="estado-box sin_datos" id="estadoBox">
213
+ <div class="estado-label">// Estado del paciente</div>
214
  <div class="estado-valor sin_datos" id="estadoValor">SIN DATOS</div>
215
  <div class="motivo-texto" id="motivoTexto"></div>
216
  </div>
 
237
  </div>
238
 
239
  <a href="/app" class="btn-vigilante">⚕ Ir a pantalla de vigilancia</a>
 
240
  </div>
241
 
242
  <script>
 
244
  let alarmaActiva = false;
245
  let ultimoEstado = "";
246
 
247
+ // ── PEERJS ──────────────────────────────────────────
248
+ let peer = null;
249
+ let llamadaActual = null;
250
+
251
+ function iniciarPeerFamiliar() {
252
+ peer = new Peer({
253
+ host: '0.peerjs.com',
254
+ port: 443,
255
+ secure: true,
256
+ config: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
257
+ });
258
+ peer.on('error', err => {
259
+ setEstadoCam('Error PeerJS: ' + err.type, 'error');
260
+ });
261
+ }
262
+
263
+ function conectarCamara() {
264
+ const idDestino = document.getElementById('peerIdInput').value.trim();
265
+ if (!idDestino) { setEstadoCam('Introduce un ID válido', 'error'); return; }
266
+
267
+ document.getElementById('btnConectar').disabled = true;
268
+ setEstadoCam('Conectando...', '');
269
+
270
+ if (!peer) iniciarPeerFamiliar();
271
+
272
+ // Esperar a que peer esté abierto
273
+ const intentarLlamada = () => {
274
+ const call = peer.call(idDestino, null); // null = solo recibir stream
275
+ // PeerJS requiere enviar algo — enviamos un stream vacío de audio
276
+ navigator.mediaDevices.getUserMedia({ video: false, audio: true })
277
+ .then(silentStream => {
278
+ // Silenciar el audio local
279
+ silentStream.getAudioTracks().forEach(t => t.enabled = false);
280
+ const call2 = peer.call(idDestino, silentStream);
281
+ llamadaActual = call2;
282
+
283
+ call2.on('stream', stream => {
284
+ const video = document.getElementById('videoRemoto');
285
+ video.srcObject = stream;
286
+ document.getElementById('camaraContainer').classList.add('visible');
287
+ setEstadoCam('✓ Cámara en vivo conectada', 'conectado');
288
+ });
289
+
290
+ call2.on('close', () => {
291
+ setEstadoCam('Cámara desconectada', 'error');
292
+ document.getElementById('camaraContainer').classList.remove('visible');
293
+ document.getElementById('btnConectar').disabled = false;
294
+ });
295
+
296
+ call2.on('error', err => {
297
+ setEstadoCam('Error: ' + err, 'error');
298
+ document.getElementById('btnConectar').disabled = false;
299
+ });
300
+ })
301
+ .catch(() => {
302
+ // Si no hay micrófono, usamos stream vacío sintético
303
+ const ctx = new AudioContext();
304
+ const dest = ctx.createMediaStreamDestination();
305
+ const call3 = peer.call(idDestino, dest.stream);
306
+ llamadaActual = call3;
307
+
308
+ call3.on('stream', stream => {
309
+ document.getElementById('videoRemoto').srcObject = stream;
310
+ document.getElementById('camaraContainer').classList.add('visible');
311
+ setEstadoCam('✓ Cámara en vivo conectada', 'conectado');
312
+ });
313
+ call3.on('close', () => {
314
+ setEstadoCam('Cámara desconectada', 'error');
315
+ document.getElementById('camaraContainer').classList.remove('visible');
316
+ document.getElementById('btnConectar').disabled = false;
317
+ });
318
+ });
319
+ };
320
+
321
+ if (peer.open) {
322
+ intentarLlamada();
323
+ } else {
324
+ peer.on('open', () => intentarLlamada());
325
+ }
326
+ }
327
+
328
+ function setEstadoCam(msg, tipo) {
329
+ const el = document.getElementById('peerEstadoCam');
330
+ el.textContent = msg;
331
+ el.className = 'peer-estado-cam ' + tipo;
332
+ }
333
+ // ────────────────────────────────────────────────────
334
+
335
  async function actualizarDatos() {
336
  try {
337
  const resp = await fetch(BACKEND);
338
  const data = await resp.json();
339
 
340
+ document.getElementById('nombrePaciente').textContent = '👤 ' + (data.nombre || 'Paciente');
341
+ document.getElementById('pulsoValor').innerHTML = (data.pulso || '--') + '<span class="constante-unidad">BPM</span>';
342
+ document.getElementById('oxigenoValor').innerHTML = (data.oxigeno || '--') + '<span class="constante-unidad">%</span>';
343
+ document.getElementById('sonidoTexto').textContent = data.sonido || 'Sin datos';
344
+ document.getElementById('ultimaActualizacion').textContent = 'Última actualización: ' + (data.ultima_actualizacion || '--');
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  const estado = (data.estado || 'SIN_DATOS').toUpperCase();
347
+ const box = document.getElementById('estadoBox');
348
+ const valor = document.getElementById('estadoValor');
349
  const motivo = document.getElementById('motivoTexto');
350
 
351
+ const clase = estado === 'CRITICO' ? 'critico' : estado === 'NORMAL' ? 'normal' : 'sin_datos';
352
+ box.className = 'estado-box ' + clase;
353
+ valor.className = 'estado-valor ' + clase;
354
+ valor.textContent = estado;
355
+ motivo.textContent = data.motivo || '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
+ if (estado === 'CRITICO' && !alarmaActiva && ultimoEstado !== 'CRITICO') disparaAlarma(data.motivo);
358
  ultimoEstado = estado;
359
 
360
+ } catch(e) {
361
+ document.getElementById('ultimaActualizacion').textContent = 'Error de conexión';
 
362
  }
363
  }
364
 
365
+ function disparaAlarma(motivo) {
366
  alarmaActiva = true;
367
  document.getElementById('alarmaMotivo').textContent = motivo;
368
  document.getElementById('alarmaOverlay').classList.add('activa');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  }
370
 
371
  function silenciarAlarma() {
 
373
  document.getElementById('alarmaOverlay').classList.remove('activa');
374
  }
375
 
 
376
  actualizarDatos();
377
+ setInterval(actualizarDatos, 8000);
378
  </script>
379
  </body>
380
  </html>
index.html CHANGED
@@ -1,752 +1,819 @@
1
  <!DOCTYPE html>
2
  <html lang="es">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Vigilante Paciente</title>
7
- <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@300;600;800&display=swap" rel="stylesheet">
8
- <style>
9
- :root {
10
- --bg: #030a0e;
11
- --panel: #0a1a22;
12
- --border: #0e3a50;
13
- --cyan: #00e5ff;
14
- --green: #00ff88;
15
- --red: #ff2244;
16
- --yellow: #ffcc00;
17
- --text: #c8e8f0;
18
- --mono: 'Share Tech Mono', monospace;
19
- --display: 'Exo 2', sans-serif;
20
- }
21
-
22
- * { margin: 0; padding: 0; box-sizing: border-box; }
23
-
24
- body {
25
- background: var(--bg);
26
- color: var(--text);
27
- font-family: var(--display);
28
- min-height: 100vh;
29
- overflow-x: hidden;
30
- }
31
-
32
- /* Fondo animado */
33
- body::before {
34
- content: '';
35
- position: fixed;
36
- inset: 0;
37
- background:
38
- radial-gradient(ellipse at 20% 50%, rgba(0,229,255,0.04) 0%, transparent 60%),
39
- radial-gradient(ellipse at 80% 20%, rgba(0,255,136,0.03) 0%, transparent 50%);
40
- pointer-events: none;
41
- z-index: 0;
42
- }
43
-
44
- .grid-bg {
45
- position: fixed;
46
- inset: 0;
47
- background-image:
48
- linear-gradient(rgba(0,229,255,0.04) 1px, transparent 1px),
49
- linear-gradient(90deg, rgba(0,229,255,0.04) 1px, transparent 1px);
50
- background-size: 40px 40px;
51
- pointer-events: none;
52
- z-index: 0;
53
- }
54
-
55
- .container {
56
- position: relative;
57
- z-index: 1;
58
- max-width: 480px;
59
- margin: 0 auto;
60
- padding: 16px;
61
- }
62
-
63
- /* Header */
64
- .header {
65
- text-align: center;
66
- padding: 20px 0 16px;
67
- border-bottom: 1px solid var(--border);
68
- margin-bottom: 16px;
69
- }
70
-
71
- .header h1 {
72
- font-size: 1.4rem;
73
- font-weight: 800;
74
- letter-spacing: 0.15em;
75
- color: var(--cyan);
76
- text-shadow: 0 0 20px rgba(0,229,255,0.5);
77
- text-transform: uppercase;
78
- }
79
-
80
- .header p {
81
- font-family: var(--mono);
82
- font-size: 0.65rem;
83
- color: #4a7a8a;
84
- margin-top: 4px;
85
- letter-spacing: 0.1em;
86
- }
87
-
88
- /* Estado principal */
89
- .estado-box {
90
- border: 2px solid var(--border);
91
- border-radius: 8px;
92
- padding: 16px;
93
- margin-bottom: 14px;
94
- background: var(--panel);
95
- position: relative;
96
- overflow: hidden;
97
- transition: border-color 0.3s, box-shadow 0.3s;
98
- }
99
-
100
- .estado-box.normal { border-color: var(--green); box-shadow: 0 0 20px rgba(0,255,136,0.1); }
101
- .estado-box.critico { border-color: var(--red); box-shadow: 0 0 30px rgba(255,34,68,0.3); animation: pulseRed 0.8s infinite; }
102
- .estado-box.esperando { border-color: var(--border); }
103
-
104
- @keyframes pulseRed {
105
- 0%, 100% { box-shadow: 0 0 20px rgba(255,34,68,0.3); }
106
- 50% { box-shadow: 0 0 50px rgba(255,34,68,0.7); }
107
- }
108
-
109
- .estado-label {
110
- font-family: var(--mono);
111
- font-size: 0.6rem;
112
- letter-spacing: 0.2em;
113
- color: #4a7a8a;
114
- text-transform: uppercase;
115
- margin-bottom: 6px;
116
- }
117
-
118
- .estado-valor {
119
- font-size: 2rem;
120
- font-weight: 800;
121
- letter-spacing: 0.1em;
122
- text-transform: uppercase;
123
- }
124
-
125
- .estado-valor.normal { color: var(--green); text-shadow: 0 0 15px rgba(0,255,136,0.5); }
126
- .estado-valor.critico { color: var(--red); text-shadow: 0 0 15px rgba(255,34,68,0.7); }
127
- .estado-valor.esperando { color: #4a7a8a; }
128
-
129
- .motivo-texto {
130
- font-family: var(--mono);
131
- font-size: 0.75rem;
132
- color: var(--yellow);
133
- margin-top: 8px;
134
- min-height: 16px;
135
- }
136
-
137
- /* Cámara */
138
- .camara-container {
139
- border: 1px solid var(--border);
140
- border-radius: 8px;
141
- overflow: hidden;
142
- margin-bottom: 14px;
143
- position: relative;
144
- background: #000;
145
- aspect-ratio: 4/3;
146
- }
147
-
148
- #video {
149
- width: 100%;
150
- height: 100%;
151
- object-fit: cover;
152
- display: block;
153
- transform: scaleX(-1);
154
- }
155
-
156
- .camara-overlay {
157
- position: absolute;
158
- inset: 0;
159
- pointer-events: none;
160
- }
161
-
162
- /* Esquinas decorativas */
163
- .corner {
164
- position: absolute;
165
- width: 20px;
166
- height: 20px;
167
- border-color: var(--cyan);
168
- border-style: solid;
169
- opacity: 0.7;
170
- }
171
- .corner.tl { top: 8px; left: 8px; border-width: 2px 0 0 2px; }
172
- .corner.tr { top: 8px; right: 8px; border-width: 2px 2px 0 0; }
173
- .corner.bl { bottom: 8px; left: 8px; border-width: 0 0 2px 2px; }
174
- .corner.br { bottom: 8px; right: 8px; border-width: 0 2px 2px 0; }
175
-
176
- .camara-label {
177
- position: absolute;
178
- top: 10px;
179
- left: 50%;
180
- transform: translateX(-50%);
181
- font-family: var(--mono);
182
- font-size: 0.6rem;
183
- color: var(--cyan);
184
- letter-spacing: 0.15em;
185
- opacity: 0.8;
186
- }
187
-
188
- .rec-dot {
189
- position: absolute;
190
- top: 10px;
191
- right: 32px;
192
- width: 8px;
193
- height: 8px;
194
- background: var(--red);
195
- border-radius: 50%;
196
- animation: blink 1.2s infinite;
197
- }
198
-
199
- @keyframes blink {
200
- 0%, 100% { opacity: 1; }
201
- 50% { opacity: 0.2; }
202
- }
203
-
204
- /* Panel de constantes */
205
- .constantes {
206
- display: grid;
207
- grid-template-columns: 1fr 1fr;
208
- gap: 10px;
209
- margin-bottom: 14px;
210
- }
211
-
212
- .constante-card {
213
- background: var(--panel);
214
- border: 1px solid var(--border);
215
- border-radius: 8px;
216
- padding: 12px;
217
- }
218
-
219
- .constante-label {
220
- font-family: var(--mono);
221
- font-size: 0.55rem;
222
- color: #4a7a8a;
223
- letter-spacing: 0.15em;
224
- text-transform: uppercase;
225
- margin-bottom: 4px;
226
- }
227
-
228
- .constante-valor {
229
- font-family: var(--mono);
230
- font-size: 1.6rem;
231
- font-weight: bold;
232
- color: var(--cyan);
233
- }
234
-
235
- .constante-unidad {
236
- font-size: 0.6rem;
237
- color: #4a7a8a;
238
- margin-left: 2px;
239
- }
240
-
241
- /* Sonido */
242
- .sonido-card {
243
- background: var(--panel);
244
- border: 1px solid var(--border);
245
- border-radius: 8px;
246
- padding: 12px;
247
- margin-bottom: 14px;
248
- }
249
-
250
- .sonido-label {
251
- font-family: var(--mono);
252
- font-size: 0.55rem;
253
- color: #4a7a8a;
254
- letter-spacing: 0.15em;
255
- text-transform: uppercase;
256
- margin-bottom: 8px;
257
- }
258
-
259
- .sonido-barra-container {
260
- display: flex;
261
- align-items: center;
262
- gap: 10px;
263
- }
264
-
265
- .sonido-barra {
266
- flex: 1;
267
- height: 6px;
268
- background: var(--border);
269
- border-radius: 3px;
270
- overflow: hidden;
271
- }
272
-
273
- .sonido-fill {
274
- height: 100%;
275
- background: linear-gradient(90deg, var(--green), var(--cyan));
276
- border-radius: 3px;
277
- transition: width 0.15s;
278
- width: 0%;
279
- }
280
-
281
- .sonido-estado {
282
- font-family: var(--mono);
283
- font-size: 0.7rem;
284
- color: var(--green);
285
- min-width: 60px;
286
- text-align: right;
287
- }
288
-
289
- /* Nombre */
290
- .nombre-input {
291
- width: 100%;
292
- background: var(--panel);
293
- border: 1px solid var(--border);
294
- border-radius: 8px;
295
- padding: 10px 14px;
296
- color: var(--text);
297
- font-family: var(--mono);
298
- font-size: 0.85rem;
299
- margin-bottom: 14px;
300
- outline: none;
301
- transition: border-color 0.2s;
302
- }
303
-
304
- .nombre-input:focus { border-color: var(--cyan); }
305
- .nombre-input::placeholder { color: #2a4a5a; }
306
-
307
- /* Botones */
308
- .botones {
309
- display: grid;
310
- grid-template-columns: 1fr 1fr;
311
- gap: 10px;
312
- margin-bottom: 14px;
313
- }
314
-
315
- .btn {
316
- padding: 14px;
317
- border: none;
318
- border-radius: 8px;
319
- font-family: var(--display);
320
- font-size: 0.8rem;
321
- font-weight: 600;
322
- letter-spacing: 0.1em;
323
- text-transform: uppercase;
324
- cursor: pointer;
325
- transition: all 0.2s;
326
- }
327
-
328
- .btn-iniciar {
329
- background: var(--cyan);
330
- color: var(--bg);
331
- }
332
-
333
- .btn-iniciar:hover { background: #00ccee; transform: translateY(-1px); }
334
- .btn-iniciar:disabled { background: #0a3040; color: #2a5060; cursor: not-allowed; transform: none; }
335
-
336
- .btn-parar {
337
- background: transparent;
338
- color: var(--red);
339
- border: 1px solid var(--red);
340
- }
341
-
342
- .btn-parar:hover { background: rgba(255,34,68,0.1); }
343
- .btn-parar:disabled { opacity: 0.3; cursor: not-allowed; }
344
-
345
- /* Log */
346
- .log-box {
347
- background: var(--panel);
348
- border: 1px solid var(--border);
349
- border-radius: 8px;
350
- padding: 12px;
351
- margin-bottom: 14px;
352
- max-height: 120px;
353
- overflow-y: auto;
354
- }
355
 
356
- .log-label {
357
- font-family: var(--mono);
358
- font-size: 0.55rem;
359
- color: #4a7a8a;
360
- letter-spacing: 0.15em;
361
- text-transform: uppercase;
362
- margin-bottom: 8px;
363
- }
364
 
365
- .log-entry {
366
- font-family: var(--mono);
367
- font-size: 0.7rem;
368
- color: #5a8a9a;
369
- padding: 2px 0;
370
- border-bottom: 1px solid rgba(14,58,80,0.4);
371
- }
372
 
373
- .log-entry:last-child { border-bottom: none; }
374
- .log-entry.critico { color: var(--red); }
375
- .log-entry.normal { color: var(--green); }
376
- .log-entry.info { color: var(--cyan); }
377
-
378
- /* Alarma overlay */
379
- .alarma-overlay {
380
- display: none;
381
- position: fixed;
382
- inset: 0;
383
- background: rgba(255,34,68,0.15);
384
- z-index: 100;
385
- backdrop-filter: blur(2px);
386
- animation: flashRed 0.5s infinite;
387
- }
388
 
389
- .alarma-overlay.activa { display: flex; align-items: center; justify-content: center; }
390
 
391
- @keyframes flashRed {
392
- 0%, 100% { background: rgba(255,34,68,0.1); }
393
- 50% { background: rgba(255,34,68,0.3); }
394
- }
 
395
 
396
- .alarma-card {
397
- background: #1a0508;
398
- border: 2px solid var(--red);
399
- border-radius: 12px;
400
- padding: 30px;
401
- text-align: center;
402
- max-width: 300px;
403
- box-shadow: 0 0 60px rgba(255,34,68,0.5);
404
- }
 
 
405
 
406
- .alarma-icono { font-size: 3rem; margin-bottom: 10px; }
 
 
 
 
 
 
 
 
 
407
 
408
- .alarma-titulo {
409
- font-size: 1.5rem;
410
- font-weight: 800;
411
- color: var(--red);
412
- letter-spacing: 0.15em;
413
- text-transform: uppercase;
414
- margin-bottom: 10px;
415
- }
416
 
417
- .alarma-motivo {
418
- font-family: var(--mono);
419
- font-size: 0.8rem;
420
- color: var(--text);
421
- margin-bottom: 20px;
422
- line-height: 1.5;
423
- }
424
 
425
- .btn-silenciar {
426
- background: var(--red);
427
- color: white;
428
- border: none;
429
- border-radius: 6px;
430
- padding: 12px 24px;
431
- font-family: var(--display);
432
- font-weight: 600;
433
- font-size: 0.85rem;
434
- cursor: pointer;
435
- letter-spacing: 0.1em;
436
- text-transform: uppercase;
437
- }
438
 
439
- /* Scrollbar */
440
- ::-webkit-scrollbar { width: 4px; }
441
- ::-webkit-scrollbar-track { background: var(--bg); }
442
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
443
- </style>
444
- <link rel="manifest" href="/manifest.json">
445
- <script>if("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js");</script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  </head>
447
  <body>
448
-
449
  <div class="grid-bg"></div>
450
 
451
- <!-- Alarma overlay -->
452
  <div class="alarma-overlay" id="alarmaOverlay">
453
  <div class="alarma-card">
454
  <div class="alarma-icono">🚨</div>
455
  <div class="alarma-titulo">¡ALERTA CRÍTICA!</div>
456
- <div class="alarma-motivo" id="alarmaMotivo">Detectado problema en el paciente</div>
457
  <button class="btn-silenciar" onclick="silenciarAlarma()">SILENCIAR</button>
458
  </div>
459
  </div>
460
 
461
  <div class="container">
462
-
463
  <div class="header">
464
  <h1>⚕ Vigilante Paciente</h1>
465
- <p>SISTEMA DE MONITORIZACIÓN SIN CONTACTO · v1.0</p>
466
  </div>
467
 
468
- <!-- Estado IA -->
 
 
 
 
 
 
 
 
 
469
  <div class="estado-box esperando" id="estadoBox">
470
- <div class="estado-label">// ESTADO DEL SISTEMA</div>
471
- <div class="estado-valor esperando" id="estadoValor">EN ESPERA</div>
472
  <div class="motivo-texto" id="motivoTexto"></div>
473
  </div>
474
 
475
- <!-- Cámara -->
476
  <div class="camara-container">
477
- <video id="video" autoplay playsinline muted></video>
478
  <div class="camara-overlay">
479
  <div class="corner tl"></div>
480
  <div class="corner tr"></div>
481
  <div class="corner bl"></div>
482
  <div class="corner br"></div>
483
- <div class="camara-label">CÁMARA ACTIVA</div>
484
- <div class="rec-dot" id="recDot" style="display:none"></div>
485
  </div>
486
  </div>
487
 
488
- <!-- Constantes -->
489
  <div class="constantes">
490
  <div class="constante-card">
491
- <div class="constante-label">// Pulso estimado</div>
492
  <div class="constante-valor" id="pulsoValor">--<span class="constante-unidad">BPM</span></div>
493
  </div>
494
  <div class="constante-card">
495
- <div class="constante-label">// Oxígeno estimado</div>
496
  <div class="constante-valor" id="oxigenoValor">--<span class="constante-unidad">%</span></div>
497
  </div>
498
  </div>
499
 
500
- <!-- Sonido -->
501
  <div class="sonido-card">
502
- <div class="sonido-label">// Nivel de sonido ambiente</div>
503
  <div class="sonido-barra-container">
504
- <div class="sonido-barra">
505
- <div class="sonido-fill" id="sonidoFill"></div>
506
- </div>
507
- <div class="sonido-estado" id="sonidoEstado">SILENCIO</div>
508
  </div>
509
  </div>
510
 
511
- <!-- Nombre -->
512
- <input type="text" class="nombre-input" id="nombreInput" placeholder="Nombre del paciente..." value="Paciente">
513
-
514
- <!-- Botones -->
515
  <div class="botones">
516
- <button class="btn btn-iniciar" id="btnIniciar" onclick="iniciarVigilancia()">▶ Iniciar</button>
517
- <button class="btn btn-parar" id="btnParar" onclick="pararVigilancia()" disabled>■ Parar</button>
518
  </div>
519
 
520
- <!-- Log -->
521
  <div class="log-box">
522
  <div class="log-label">// Registro de eventos</div>
523
- <div id="logContainer">
524
- <div class="log-entry info">Sistema iniciado. Listo para vigilar.</div>
525
- </div>
526
  </div>
527
-
528
  </div>
529
 
530
  <script>
531
- // ─── CONFIGURACIÓN ───────────────────────────────────────────
532
- const BACKEND_URL = "https://cristobal299-iaweb.hf.space/vigilar";
533
- const INTERVALO_SEGUNDOS = 15;
534
-
535
- // ─── ESTADO GLOBAL ───────────────────────────────────────────
536
- let vigilanciaActiva = false;
537
  let intervalId = null;
 
538
  let audioContext = null;
539
  let analyser = null;
540
- let microfonoStream = null;
541
- let camaraStream = null;
542
- let nivelSonido = 0;
543
  let alarmaAudio = null;
544
 
545
- // ── INICIAR VIGILANCIA ──────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  async function iniciarVigilancia() {
 
 
547
  try {
548
- // Cámara
549
- camaraStream = await navigator.mediaDevices.getUserMedia({
550
- video: { facingMode: 'user', width: 640, height: 480 },
551
- audio: false
552
- });
553
- document.getElementById('video').srcObject = camaraStream;
554
 
555
- // Micrófono
556
- microfonoStream = await navigator.mediaDevices.getUserMedia({ audio: true });
557
- audioContext = new (window.AudioContext || window.webkitAudioContext)();
558
- const source = audioContext.createMediaStreamSource(microfonoStream);
559
  analyser = audioContext.createAnalyser();
560
  analyser.fftSize = 256;
561
- source.connect(analyser);
562
- iniciarVisualizacionSonido();
563
 
564
- vigilanciaActiva = true;
565
  document.getElementById('btnIniciar').disabled = true;
566
  document.getElementById('btnParar').disabled = false;
567
- document.getElementById('recDot').style.display = 'block';
 
 
 
568
 
569
- agregarLog('Vigilancia iniciada ✓', 'info');
570
-
571
- // Primera llamada inmediata
572
- await bucleVigilancia();
573
-
574
- // Bucle cada X segundos
575
- intervalId = setInterval(bucleVigilancia, INTERVALO_SEGUNDOS * 1000);
576
 
577
  } catch (err) {
578
- agregarLog('Error: ' + err.message, 'critico');
579
- alert('Necesito acceso a cámara y micrófono para funcionar.');
580
  }
581
  }
582
 
583
- // ─── PARAR VIGILANCIA ────────────────────────────────────────
584
  function pararVigilancia() {
585
- vigilanciaActiva = false;
586
  clearInterval(intervalId);
587
-
588
- if (camaraStream) camaraStream.getTracks().forEach(t => t.stop());
589
- if (microfonoStream) microfonoStream.getTracks().forEach(t => t.stop());
590
- if (audioContext) audioContext.close();
591
-
 
592
  document.getElementById('video').srcObject = null;
 
593
  document.getElementById('btnIniciar').disabled = false;
594
  document.getElementById('btnParar').disabled = true;
595
- document.getElementById('recDot').style.display = 'none';
596
  document.getElementById('sonidoFill').style.width = '0%';
597
- document.getElementById('sonidoEstado').textContent = 'SILENCIO';
598
-
599
- actualizarEstado('EN ESPERA', 'esperando', '');
600
  agregarLog('Vigilancia detenida', 'info');
601
  }
602
 
603
- // ─── BUCLE PRINCIPAL ─────────────────────────────────────────
604
- async function bucleVigilancia() {
605
- if (!vigilanciaActiva) return;
606
-
607
- const nombre = document.getElementById('nombreInput').value || 'Paciente';
608
-
609
- // Simular pulso con variación realista (paciente en reposo)
610
- const pulso = Math.floor(65 + Math.random() * 20 + (nivelSonido > 60 ? 15 : 0));
611
- const oxigeno = Math.floor(94 + Math.random() * 4);
612
-
613
- // Clasificar sonido
614
- let descripcionSonido = "silencio";
615
- if (nivelSonido > 75) descripcionSonido = "sonido muy alto detectado, posible grito o golpe";
616
- else if (nivelSonido > 55) descripcionSonido = "ruido elevado, posible quejido o movimiento brusco";
617
- else if (nivelSonido > 30) descripcionSonido = "ruido leve de fondo";
618
-
619
- // Actualizar UI
620
- document.getElementById('pulsoValor').innerHTML = pulso + '<span class="constante-unidad">BPM</span>';
 
 
 
 
621
  document.getElementById('oxigenoValor').innerHTML = oxigeno + '<span class="constante-unidad">%</span>';
622
 
623
- agregarLog(`Enviando → Pulso:${pulso} O2:${oxigeno}% Sonido:"${descripcionSonido}"`, 'info');
624
-
625
  try {
626
- const resp = await fetch(BACKEND_URL, {
627
  method: 'POST',
628
  headers: { 'Content-Type': 'application/json' },
629
  body: JSON.stringify({
630
- nombre: nombre,
631
- pulso: pulso,
632
- oxigeno: oxigeno,
633
- sonido_ambiente: descripcionSonido,
634
- movimiento_respiracion: "regular"
635
  })
636
  });
637
-
638
- const texto = await resp.text();
639
- let data;
640
-
641
- try {
642
- // A veces viene como string JSON dentro de string
643
- data = JSON.parse(texto);
644
- if (typeof data === 'string') data = JSON.parse(data);
645
- } catch(e) {
646
- agregarLog('Error parseando respuesta', 'critico');
647
- return;
648
- }
649
-
650
- if (data.estado === 'CRITICO') {
651
- actualizarEstado('⚠ CRÍTICO', 'critico', data.motivo || '');
652
- agregarLog('CRÍTICO: ' + (data.motivo || ''), 'critico');
653
- dispararAlarma(data.motivo || 'Estado crítico detectado');
654
- } else {
655
- actualizarEstado('NORMAL', 'normal', '');
656
- agregarLog('Estado: NORMAL ✓', 'normal');
657
- }
658
-
659
- } catch (err) {
660
- agregarLog('Error de conexión: ' + err.message, 'critico');
661
  }
662
  }
663
 
664
- // ─── VISUALIZACIÓN SONIDO ────────────────────────────────────
665
- function iniciarVisualizacionSonido() {
666
- const dataArray = new Uint8Array(analyser.frequencyBinCount);
667
-
668
- function actualizar() {
669
- if (!vigilanciaActiva) return;
670
- analyser.getByteFrequencyData(dataArray);
671
- const promedio = dataArray.reduce((a, b) => a + b) / dataArray.length;
672
- nivelSonido = promedio;
673
-
674
- const porcentaje = Math.min(100, (promedio / 128) * 100);
675
- document.getElementById('sonidoFill').style.width = porcentaje + '%';
676
-
677
- let estado = 'SILENCIO';
678
- let color = 'var(--green)';
679
- if (promedio > 75) { estado = 'ALERTA'; color = 'var(--red)'; }
680
- else if (promedio > 55) { estado = 'RUIDO'; color = 'var(--yellow)'; }
681
- else if (promedio > 30) { estado = 'LEVE'; color = 'var(--cyan)'; }
682
-
683
- document.getElementById('sonidoEstado').textContent = estado;
684
- document.getElementById('sonidoEstado').style.color = color;
685
-
686
- requestAnimationFrame(actualizar);
687
- }
688
- actualizar();
689
- }
690
 
691
- // ─── ESTADO UI ───────────────────────────────────────────────
692
- function actualizarEstado(texto, clase, motivo) {
693
- const box = document.getElementById('estadoBox');
694
- const valor = document.getElementById('estadoValor');
695
- const motivoEl = document.getElementById('motivoTexto');
696
-
697
- box.className = 'estado-box ' + clase;
698
- valor.className = 'estado-valor ' + clase;
699
- valor.textContent = texto;
700
- motivoEl.textContent = motivo;
701
- }
702
 
703
- // ─── LOG ─────────────────────────────────────────────────────
704
- function agregarLog(mensaje, tipo) {
705
- const container = document.getElementById('logContainer');
706
- const entry = document.createElement('div');
707
- entry.className = 'log-entry ' + (tipo || '');
708
- const hora = new Date().toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
709
- entry.textContent = `[${hora}] ${mensaje}`;
710
- container.appendChild(entry);
711
- container.scrollTop = container.scrollHeight;
712
-
713
- // Máximo 50 entradas
714
- while (container.children.length > 50) {
715
- container.removeChild(container.firstChild);
716
- }
717
  }
718
 
719
- // ─── ALARMA ──────────────────────────────────────────────────
720
- function dispararAlarma(motivo) {
721
  document.getElementById('alarmaMotivo').textContent = motivo;
722
  document.getElementById('alarmaOverlay').classList.add('activa');
723
-
724
- // Sonido de alarma con Web Audio API
725
  try {
726
- const ctx = new AudioContext();
727
- function beep(freq, start, dur) {
728
- const osc = ctx.createOscillator();
729
- const gain = ctx.createGain();
730
- osc.connect(gain);
731
- gain.connect(ctx.destination);
732
- osc.frequency.value = freq;
733
- osc.type = 'square';
734
- gain.gain.setValueAtTime(0.3, ctx.currentTime + start);
735
- gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + start + dur);
736
- osc.start(ctx.currentTime + start);
737
- osc.stop(ctx.currentTime + start + dur);
738
- }
739
- // Patrón de alarma
740
- for (let i = 0; i < 6; i++) {
741
- beep(880, i * 0.4, 0.3);
742
- beep(660, i * 0.4 + 0.2, 0.15);
743
- }
744
  } catch(e) {}
745
  }
746
 
747
  function silenciarAlarma() {
 
748
  document.getElementById('alarmaOverlay').classList.remove('activa');
 
749
  }
 
 
 
 
 
 
 
 
 
 
 
 
750
  </script>
751
  </body>
752
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="es">
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Vigilante Paciente</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@300;600;800&display=swap" rel="stylesheet">
8
+ <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
9
+ <style>
10
+ :root {
11
+ --bg: #030a0e;
12
+ --panel: #0a1a22;
13
+ --border: #0e3a50;
14
+ --cyan: #00e5ff;
15
+ --green: #00ff88;
16
+ --red: #ff2244;
17
+ --yellow: #ffcc00;
18
+ --text: #c8e8f0;
19
+ --mono: 'Share Tech Mono', monospace;
20
+ --display: 'Exo 2', sans-serif;
21
+ }
22
+ * { margin: 0; padding: 0; box-sizing: border-box; }
23
+ body {
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ font-family: var(--display);
27
+ min-height: 100vh;
28
+ overflow-x: hidden;
29
+ }
30
+ body::before {
31
+ content: '';
32
+ position: fixed;
33
+ inset: 0;
34
+ background:
35
+ radial-gradient(ellipse at 20% 50%, rgba(0,229,255,0.04) 0%, transparent 60%),
36
+ radial-gradient(ellipse at 80% 20%, rgba(0,255,136,0.03) 0%, transparent 50%);
37
+ pointer-events: none;
38
+ z-index: 0;
39
+ }
40
+ .grid-bg {
41
+ position: fixed;
42
+ inset: 0;
43
+ background-image:
44
+ linear-gradient(rgba(0,229,255,0.04) 1px, transparent 1px),
45
+ linear-gradient(90deg, rgba(0,229,255,0.04) 1px, transparent 1px);
46
+ background-size: 40px 40px;
47
+ pointer-events: none;
48
+ z-index: 0;
49
+ }
50
+ .container {
51
+ position: relative;
52
+ z-index: 1;
53
+ max-width: 480px;
54
+ margin: 0 auto;
55
+ padding: 16px;
56
+ }
57
+ .header {
58
+ text-align: center;
59
+ padding: 20px 0 16px;
60
+ border-bottom: 1px solid var(--border);
61
+ margin-bottom: 16px;
62
+ }
63
+ .header h1 {
64
+ font-size: 1.4rem;
65
+ font-weight: 800;
66
+ letter-spacing: 0.15em;
67
+ color: var(--cyan);
68
+ text-shadow: 0 0 20px rgba(0,229,255,0.5);
69
+ text-transform: uppercase;
70
+ }
71
+ .header p {
72
+ font-family: var(--mono);
73
+ font-size: 0.65rem;
74
+ color: #4a7a8a;
75
+ margin-top: 4px;
76
+ letter-spacing: 0.1em;
77
+ }
78
+ /* ── PEER ID BOX ── */
79
+ .peer-box {
80
+ display: none;
81
+ background: var(--panel);
82
+ border: 1px solid var(--cyan);
83
+ border-radius: 8px;
84
+ padding: 12px 14px;
85
+ margin-bottom: 14px;
86
+ font-family: var(--mono);
87
+ font-size: 0.72rem;
88
+ color: var(--cyan);
89
+ letter-spacing: 0.08em;
90
+ word-break: break-all;
91
+ box-shadow: 0 0 14px rgba(0,229,255,0.12);
92
+ }
93
+ .peer-box .peer-label {
94
+ font-size: 0.55rem;
95
+ color: #4a7a8a;
96
+ letter-spacing: 0.2em;
97
+ text-transform: uppercase;
98
+ margin-bottom: 4px;
99
+ }
100
+ .peer-id-text {
101
+ color: var(--green);
102
+ font-size: 0.8rem;
103
+ font-weight: bold;
104
+ word-break: break-all;
105
+ }
106
+ .btn-copiar {
107
+ margin-top: 8px;
108
+ width: 100%;
109
+ padding: 8px;
110
+ background: rgba(0,229,255,0.1);
111
+ border: 1px solid var(--cyan);
112
+ border-radius: 6px;
113
+ color: var(--cyan);
114
+ font-family: var(--mono);
115
+ font-size: 0.65rem;
116
+ letter-spacing: 0.1em;
117
+ cursor: pointer;
118
+ text-transform: uppercase;
119
+ transition: background 0.2s;
120
+ }
121
+ .btn-copiar:hover { background: rgba(0,229,255,0.2); }
122
+ .peer-conectados {
123
+ margin-top: 6px;
124
+ font-size: 0.6rem;
125
+ color: var(--green);
126
+ letter-spacing: 0.1em;
127
+ }
128
+ /* Estado principal */
129
+ .estado-box {
130
+ border: 2px solid var(--border);
131
+ border-radius: 8px;
132
+ padding: 16px;
133
+ margin-bottom: 14px;
134
+ background: var(--panel);
135
+ position: relative;
136
+ overflow: hidden;
137
+ transition: border-color 0.3s, box-shadow 0.3s;
138
+ }
139
+ .estado-box.normal { border-color: var(--green); box-shadow: 0 0 20px rgba(0,255,136,0.1); }
140
+ .estado-box.critico { border-color: var(--red); box-shadow: 0 0 30px rgba(255,34,68,0.3); animation: pulseRed 0.8s infinite; }
141
+ .estado-box.esperando { border-color: var(--border); }
142
+ @keyframes pulseRed {
143
+ 0%, 100% { box-shadow: 0 0 20px rgba(255,34,68,0.3); }
144
+ 50% { box-shadow: 0 0 50px rgba(255,34,68,0.7); }
145
+ }
146
+ .estado-label {
147
+ font-family: var(--mono);
148
+ font-size: 0.6rem;
149
+ letter-spacing: 0.2em;
150
+ color: #4a7a8a;
151
+ text-transform: uppercase;
152
+ margin-bottom: 6px;
153
+ }
154
+ .estado-valor { font-size: 2rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
155
+ .estado-valor.normal { color: var(--green); text-shadow: 0 0 15px rgba(0,255,136,0.5); }
156
+ .estado-valor.critico { color: var(--red); text-shadow: 0 0 15px rgba(255,34,68,0.7); }
157
+ .estado-valor.esperando { color: #4a7a8a; }
158
+ .motivo-texto {
159
+ font-family: var(--mono);
160
+ font-size: 0.75rem;
161
+ color: var(--yellow);
162
+ margin-top: 8px;
163
+ min-height: 16px;
164
+ }
165
+ /* Cámara */
166
+ .camara-container {
167
+ border: 1px solid var(--border);
168
+ border-radius: 8px;
169
+ overflow: hidden;
170
+ margin-bottom: 14px;
171
+ position: relative;
172
+ background: #000;
173
+ aspect-ratio: 4/3;
174
+ }
175
+ #video {
176
+ width: 100%;
177
+ height: 100%;
178
+ object-fit: cover;
179
+ display: block;
180
+ transform: scaleX(-1);
181
+ }
182
+ .camara-overlay { position: absolute; inset: 0; pointer-events: none; }
183
+ .corner { position: absolute; width: 20px; height: 20px; border-color: var(--cyan); border-style: solid; opacity: 0.7; }
184
+ .corner.tl { top: 8px; left: 8px; border-width: 2px 0 0 2px; }
185
+ .corner.tr { top: 8px; right: 8px; border-width: 2px 2px 0 0; }
186
+ .corner.bl { bottom: 8px; left: 8px; border-width: 0 0 2px 2px; }
187
+ .corner.br { bottom: 8px; right: 8px; border-width: 0 2px 2px 0; }
188
+ .camara-label {
189
+ position: absolute;
190
+ top: 10px; left: 50%;
191
+ transform: translateX(-50%);
192
+ font-family: var(--mono);
193
+ font-size: 0.6rem;
194
+ color: var(--cyan);
195
+ letter-spacing: 0.15em;
196
+ opacity: 0.8;
197
+ }
198
+ .rec-dot {
199
+ position: absolute;
200
+ top: 10px; right: 32px;
201
+ width: 8px; height: 8px;
202
+ background: var(--red);
203
+ border-radius: 50%;
204
+ animation: blink 1.2s infinite;
205
+ }
206
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
207
+ /* Constantes */
208
+ .constantes { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
209
+ .constante-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
210
+ .constante-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 4px; }
211
+ .constante-valor { font-family: var(--mono); font-size: 1.6rem; font-weight: bold; color: var(--cyan); }
212
+ .constante-unidad { font-size: 0.6rem; color: #4a7a8a; margin-left: 2px; }
213
+ /* Sonido */
214
+ .sonido-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 14px; }
215
+ .sonido-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
216
+ .sonido-barra-container { display: flex; align-items: center; gap: 10px; }
217
+ .sonido-barra { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
218
+ .sonido-fill { height: 100%; background: linear-gradient(90deg, var(--green), var(--cyan)); border-radius: 3px; transition: width 0.15s; width: 0%; }
219
+ .sonido-estado { font-family: var(--mono); font-size: 0.7rem; color: var(--green); min-width: 60px; text-align: right; }
220
+ /* Nombre */
221
+ .nombre-input {
222
+ width: 100%; background: var(--panel); border: 1px solid var(--border);
223
+ border-radius: 8px; padding: 10px 14px; color: var(--text);
224
+ font-family: var(--mono); font-size: 0.85rem; margin-bottom: 14px;
225
+ outline: none; transition: border-color 0.2s;
226
+ }
227
+ .nombre-input:focus { border-color: var(--cyan); }
228
+ .nombre-input::placeholder { color: #2a4a5a; }
229
+ /* Botones */
230
+ .botones { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
231
+ .btn { padding: 14px; border: none; border-radius: 8px; font-family: var(--display); font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; transition: all 0.2s; }
232
+ .btn-iniciar { background: var(--cyan); color: var(--bg); }
233
+ .btn-iniciar:hover { background: #00ccee; transform: translateY(-1px); }
234
+ .btn-iniciar:disabled { background: #0a3040; color: #2a5060; cursor: not-allowed; transform: none; }
235
+ .btn-parar { background: transparent; color: var(--red); border: 1px solid var(--red); }
236
+ .btn-parar:hover { background: rgba(255,34,68,0.1); }
237
+ .btn-parar:disabled { opacity: 0.3; cursor: not-allowed; }
238
+ /* Log */
239
+ .log-box { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 14px; max-height: 120px; overflow-y: auto; }
240
+ .log-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
241
+ .log-entry { font-family: var(--mono); font-size: 0.7rem; color: #5a8a9a; padding: 2px 0; border-bottom: 1px solid rgba(14,58,80,0.4); }
242
+ .log-entry:last-child { border-bottom: none; }
243
+ .log-entry.critico { color: var(--red); }
244
+ .log-entry.normal { color: var(--green); }
245
+ .log-entry.info { color: var(--cyan); }
246
+ /* Alarma overlay */
247
+ .alarma-overlay { display: none; position: fixed; inset: 0; background: rgba(255,34,68,0.15); z-index: 100; backdrop-filter: blur(2px); animation: flashRed 0.5s infinite; }
248
+ .alarma-overlay.activa { display: flex; align-items: center; justify-content: center; }
249
+ @keyframes flashRed { 0%, 100% { background: rgba(255,34,68,0.1); } 50% { background: rgba(255,34,68,0.3); } }
250
+ .alarma-card { background: #1a0508; border: 2px solid var(--red); border-radius: 12px; padding: 30px; text-align: center; max-width: 300px; box-shadow: 0 0 60px rgba(255,34,68,0.5); }
251
+ .alarma-icono { font-size: 3rem; margin-bottom: 10px; }
252
+ .alarma-titulo { font-size: 1.5rem; font-weight: 800; color: var(--red); letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 10px; }
253
+ .alarma-motivo { font-family: var(--mono); font-size: 0.8rem; color: var(--text); margin-bottom: 20px; line-height: 1.5; }
254
+ .btn-silenciar { background: var(--red); color: white; border: none; border-radius: 6px; padding: 12px 24px; font-family: var(--display); font-weight: 600; font-size: 0.85rem; cursor: pointer; letter-spacing: 0.1em; text-transform: uppercase; }
255
+ ::-webkit-scrollbar { width: 4px; }
256
+ ::-webkit-scrollbar-track { background: var(--bg); }
257
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="grid-bg"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ <div class="alarma-overlay" id="alarmaOverlay">
264
+ <div class="alarma-card">
265
+ <div class="alarma-icono">🚨</div>
266
+ <div class="alarma-titulo">¡ALERTA CRÍTICA!</div>
267
+ <div class="alarma-motivo" id="alarmaMotivo"></div>
268
+ <button class="btn-silenciar" onclick="silenciarAlarma()">SILENCIAR</button>
269
+ </div>
270
+ </div>
271
 
272
+ <div class="container">
273
+ <div class="header">
274
+ <h1>⚕ Vigilante Paciente</h1>
275
+ <p>MONITOR DE SALUD · IA EN TIEMPO REAL</p>
276
+ </div>
 
 
277
 
278
+ <!-- PEER ID BOX -->
279
+ <div class="peer-box" id="peerIdBox">
280
+ <div class="peer-label">// ID de cámara en vivo — comparte con el familiar</div>
281
+ <div class="peer-id-text" id="peerIdText">Generando...</div>
282
+ <button class="btn-copiar" onclick="copiarPeerId()">📋 Copiar ID</button>
283
+ <div class="peer-conectados" id="peerConectados"></div>
284
+ </div>
 
 
 
 
 
 
 
 
285
 
286
+ <input class="nombre-input" id="nombreInput" type="text" placeholder="Nombre del paciente..." maxlength="40">
287
 
288
+ <div class="estado-box esperando" id="estadoBox">
289
+ <div class="estado-label">// Estado del paciente</div>
290
+ <div class="estado-valor esperando" id="estadoValor">ESPERANDO</div>
291
+ <div class="motivo-texto" id="motivoTexto"></div>
292
+ </div>
293
 
294
+ <div class="camara-container">
295
+ <video id="video" autoplay muted playsinline></video>
296
+ <div class="camara-overlay">
297
+ <div class="corner tl"></div>
298
+ <div class="corner tr"></div>
299
+ <div class="corner bl"></div>
300
+ <div class="corner br"></div>
301
+ <div class="camara-label">CÁMARA EN VIVO</div>
302
+ <div class="rec-dot"></div>
303
+ </div>
304
+ </div>
305
 
306
+ <div class="constantes">
307
+ <div class="constante-card">
308
+ <div class="constante-label">// Pulso</div>
309
+ <div class="constante-valor" id="pulsoValor">--<span class="constante-unidad">BPM</span></div>
310
+ </div>
311
+ <div class="constante-card">
312
+ <div class="constante-label">// Oxígeno</div>
313
+ <div class="constante-valor" id="oxigenoValor">--<span class="constante-unidad">%</span></div>
314
+ </div>
315
+ </div>
316
 
317
+ <div class="sonido-card">
318
+ <div class="sonido-label">// Sonido ambiente</div>
319
+ <div class="sonido-barra-container">
320
+ <div class="sonido-barra"><div class="sonido-fill" id="sonidoFill"></div></div>
321
+ <div class="sonido-estado" id="sonidoEstado">INACTIVO</div>
322
+ </div>
323
+ </div>
 
324
 
325
+ <div class="botones">
326
+ <button class="btn btn-iniciar" id="btnIniciar" onclick="iniciarVigilancia()">▶ INICIAR</button>
327
+ <button class="btn btn-parar" id="btnParar" onclick="pararVigilancia()" disabled>■ PARAR</button>
328
+ </div>
 
 
 
329
 
330
+ <div class="log-box">
331
+ <div class="log-label">// Registro de eventos</div>
332
+ <div id="logContainer"></div>
333
+ </div>
334
+ </div>
 
 
 
 
 
 
 
 
335
 
336
+ <script>
337
+ const BACKEND = "https://cristobal299-iaweb.hf.space/vigilar";
338
+ let streamActivo = false;
339
+ let intervalId = null;
340
+ let mediaStream = null;
341
+ let audioContext = null;
342
+ let analyser = null;
343
+ let alarmaActiva = false;
344
+ let alarmaAudio = null;
345
+ /* ── PEER ID BOX ── */
346
+ .peer-box {
347
+ display: none;
348
+ background: var(--panel);
349
+ border: 1px solid var(--cyan);
350
+ border-radius: 8px;
351
+ padding: 12px 14px;
352
+ margin-bottom: 14px;
353
+ font-family: var(--mono);
354
+ font-size: 0.72rem;
355
+ color: var(--cyan);
356
+ letter-spacing: 0.08em;
357
+ word-break: break-all;
358
+ box-shadow: 0 0 14px rgba(0,229,255,0.12);
359
+ }
360
+ .peer-box .peer-label {
361
+ font-size: 0.55rem;
362
+ color: #4a7a8a;
363
+ letter-spacing: 0.2em;
364
+ text-transform: uppercase;
365
+ margin-bottom: 4px;
366
+ }
367
+ .peer-id-text {
368
+ color: var(--green);
369
+ font-size: 0.8rem;
370
+ font-weight: bold;
371
+ word-break: break-all;
372
+ }
373
+ .btn-copiar {
374
+ margin-top: 8px;
375
+ width: 100%;
376
+ padding: 8px;
377
+ background: rgba(0,229,255,0.1);
378
+ border: 1px solid var(--cyan);
379
+ border-radius: 6px;
380
+ color: var(--cyan);
381
+ font-family: var(--mono);
382
+ font-size: 0.65rem;
383
+ letter-spacing: 0.1em;
384
+ cursor: pointer;
385
+ text-transform: uppercase;
386
+ transition: background 0.2s;
387
+ }
388
+ .btn-copiar:hover { background: rgba(0,229,255,0.2); }
389
+ .peer-conectados {
390
+ margin-top: 6px;
391
+ font-size: 0.6rem;
392
+ color: var(--green);
393
+ letter-spacing: 0.1em;
394
+ }
395
+ /* Estado principal */
396
+ .estado-box {
397
+ border: 2px solid var(--border);
398
+ border-radius: 8px;
399
+ padding: 16px;
400
+ margin-bottom: 14px;
401
+ background: var(--panel);
402
+ position: relative;
403
+ overflow: hidden;
404
+ transition: border-color 0.3s, box-shadow 0.3s;
405
+ }
406
+ .estado-box.normal { border-color: var(--green); box-shadow: 0 0 20px rgba(0,255,136,0.1); }
407
+ .estado-box.critico { border-color: var(--red); box-shadow: 0 0 30px rgba(255,34,68,0.3); animation: pulseRed 0.8s infinite; }
408
+ .estado-box.esperando { border-color: var(--border); }
409
+ @keyframes pulseRed {
410
+ 0%, 100% { box-shadow: 0 0 20px rgba(255,34,68,0.3); }
411
+ 50% { box-shadow: 0 0 50px rgba(255,34,68,0.7); }
412
+ }
413
+ .estado-label {
414
+ font-family: var(--mono);
415
+ font-size: 0.6rem;
416
+ letter-spacing: 0.2em;
417
+ color: #4a7a8a;
418
+ text-transform: uppercase;
419
+ margin-bottom: 6px;
420
+ }
421
+ .estado-valor { font-size: 2rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
422
+ .estado-valor.normal { color: var(--green); text-shadow: 0 0 15px rgba(0,255,136,0.5); }
423
+ .estado-valor.critico { color: var(--red); text-shadow: 0 0 15px rgba(255,34,68,0.7); }
424
+ .estado-valor.esperando { color: #4a7a8a; }
425
+ .motivo-texto {
426
+ font-family: var(--mono);
427
+ font-size: 0.75rem;
428
+ color: var(--yellow);
429
+ margin-top: 8px;
430
+ min-height: 16px;
431
+ }
432
+ /* Cámara */
433
+ .camara-container {
434
+ border: 1px solid var(--border);
435
+ border-radius: 8px;
436
+ overflow: hidden;
437
+ margin-bottom: 14px;
438
+ position: relative;
439
+ background: #000;
440
+ aspect-ratio: 4/3;
441
+ }
442
+ #video {
443
+ width: 100%;
444
+ height: 100%;
445
+ object-fit: cover;
446
+ display: block;
447
+ transform: scaleX(-1);
448
+ }
449
+ .camara-overlay { position: absolute; inset: 0; pointer-events: none; }
450
+ .corner { position: absolute; width: 20px; height: 20px; border-color: var(--cyan); border-style: solid; opacity: 0.7; }
451
+ .corner.tl { top: 8px; left: 8px; border-width: 2px 0 0 2px; }
452
+ .corner.tr { top: 8px; right: 8px; border-width: 2px 2px 0 0; }
453
+ .corner.bl { bottom: 8px; left: 8px; border-width: 0 0 2px 2px; }
454
+ .corner.br { bottom: 8px; right: 8px; border-width: 0 2px 2px 0; }
455
+ .camara-label {
456
+ position: absolute;
457
+ top: 10px; left: 50%;
458
+ transform: translateX(-50%);
459
+ font-family: var(--mono);
460
+ font-size: 0.6rem;
461
+ color: var(--cyan);
462
+ letter-spacing: 0.15em;
463
+ opacity: 0.8;
464
+ }
465
+ .rec-dot {
466
+ position: absolute;
467
+ top: 10px; right: 32px;
468
+ width: 8px; height: 8px;
469
+ background: var(--red);
470
+ border-radius: 50%;
471
+ animation: blink 1.2s infinite;
472
+ }
473
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
474
+ /* Constantes */
475
+ .constantes { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
476
+ .constante-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
477
+ .constante-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 4px; }
478
+ .constante-valor { font-family: var(--mono); font-size: 1.6rem; font-weight: bold; color: var(--cyan); }
479
+ .constante-unidad { font-size: 0.6rem; color: #4a7a8a; margin-left: 2px; }
480
+ /* Sonido */
481
+ .sonido-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 14px; }
482
+ .sonido-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
483
+ .sonido-barra-container { display: flex; align-items: center; gap: 10px; }
484
+ .sonido-barra { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
485
+ .sonido-fill { height: 100%; background: linear-gradient(90deg, var(--green), var(--cyan)); border-radius: 3px; transition: width 0.15s; width: 0%; }
486
+ .sonido-estado { font-family: var(--mono); font-size: 0.7rem; color: var(--green); min-width: 60px; text-align: right; }
487
+ /* Nombre */
488
+ .nombre-input {
489
+ width: 100%; background: var(--panel); border: 1px solid var(--border);
490
+ border-radius: 8px; padding: 10px 14px; color: var(--text);
491
+ font-family: var(--mono); font-size: 0.85rem; margin-bottom: 14px;
492
+ outline: none; transition: border-color 0.2s;
493
+ }
494
+ .nombre-input:focus { border-color: var(--cyan); }
495
+ .nombre-input::placeholder { color: #2a4a5a; }
496
+ /* Botones */
497
+ .botones { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
498
+ .btn { padding: 14px; border: none; border-radius: 8px; font-family: var(--display); font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; transition: all 0.2s; }
499
+ .btn-iniciar { background: var(--cyan); color: var(--bg); }
500
+ .btn-iniciar:hover { background: #00ccee; transform: translateY(-1px); }
501
+ .btn-iniciar:disabled { background: #0a3040; color: #2a5060; cursor: not-allowed; transform: none; }
502
+ .btn-parar { background: transparent; color: var(--red); border: 1px solid var(--red); }
503
+ .btn-parar:hover { background: rgba(255,34,68,0.1); }
504
+ .btn-parar:disabled { opacity: 0.3; cursor: not-allowed; }
505
+ /* Log */
506
+ .log-box { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 14px; max-height: 120px; overflow-y: auto; }
507
+ .log-label { font-family: var(--mono); font-size: 0.55rem; color: #4a7a8a; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 8px; }
508
+ .log-entry { font-family: var(--mono); font-size: 0.7rem; color: #5a8a9a; padding: 2px 0; border-bottom: 1px solid rgba(14,58,80,0.4); }
509
+ .log-entry:last-child { border-bottom: none; }
510
+ .log-entry.critico { color: var(--red); }
511
+ .log-entry.normal { color: var(--green); }
512
+ .log-entry.info { color: var(--cyan); }
513
+ /* Alarma overlay */
514
+ .alarma-overlay { display: none; position: fixed; inset: 0; background: rgba(255,34,68,0.15); z-index: 100; backdrop-filter: blur(2px); animation: flashRed 0.5s infinite; }
515
+ .alarma-overlay.activa { display: flex; align-items: center; justify-content: center; }
516
+ @keyframes flashRed { 0%, 100% { background: rgba(255,34,68,0.1); } 50% { background: rgba(255,34,68,0.3); } }
517
+ .alarma-card { background: #1a0508; border: 2px solid var(--red); border-radius: 12px; padding: 30px; text-align: center; max-width: 300px; box-shadow: 0 0 60px rgba(255,34,68,0.5); }
518
+ .alarma-icono { font-size: 3rem; margin-bottom: 10px; }
519
+ .alarma-titulo { font-size: 1.5rem; font-weight: 800; color: var(--red); letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 10px; }
520
+ .alarma-motivo { font-family: var(--mono); font-size: 0.8rem; color: var(--text); margin-bottom: 20px; line-height: 1.5; }
521
+ .btn-silenciar { background: var(--red); color: white; border: none; border-radius: 6px; padding: 12px 24px; font-family: var(--display); font-weight: 600; font-size: 0.85rem; cursor: pointer; letter-spacing: 0.1em; text-transform: uppercase; }
522
+ ::-webkit-scrollbar { width: 4px; }
523
+ ::-webkit-scrollbar-track { background: var(--bg); }
524
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
525
+ </style>
526
  </head>
527
  <body>
 
528
  <div class="grid-bg"></div>
529
 
 
530
  <div class="alarma-overlay" id="alarmaOverlay">
531
  <div class="alarma-card">
532
  <div class="alarma-icono">🚨</div>
533
  <div class="alarma-titulo">¡ALERTA CRÍTICA!</div>
534
+ <div class="alarma-motivo" id="alarmaMotivo"></div>
535
  <button class="btn-silenciar" onclick="silenciarAlarma()">SILENCIAR</button>
536
  </div>
537
  </div>
538
 
539
  <div class="container">
 
540
  <div class="header">
541
  <h1>⚕ Vigilante Paciente</h1>
542
+ <p>MONITOR DE SALUD · IA EN TIEMPO REAL</p>
543
  </div>
544
 
545
+ <!-- PEER ID BOX -->
546
+ <div class="peer-box" id="peerIdBox">
547
+ <div class="peer-label">// ID de cámara en vivo — comparte con el familiar</div>
548
+ <div class="peer-id-text" id="peerIdText">Generando...</div>
549
+ <button class="btn-copiar" onclick="copiarPeerId()">📋 Copiar ID</button>
550
+ <div class="peer-conectados" id="peerConectados"></div>
551
+ </div>
552
+
553
+ <input class="nombre-input" id="nombreInput" type="text" placeholder="Nombre del paciente..." maxlength="40">
554
+
555
  <div class="estado-box esperando" id="estadoBox">
556
+ <div class="estado-label">// Estado del paciente</div>
557
+ <div class="estado-valor esperando" id="estadoValor">ESPERANDO</div>
558
  <div class="motivo-texto" id="motivoTexto"></div>
559
  </div>
560
 
 
561
  <div class="camara-container">
562
+ <video id="video" autoplay muted playsinline></video>
563
  <div class="camara-overlay">
564
  <div class="corner tl"></div>
565
  <div class="corner tr"></div>
566
  <div class="corner bl"></div>
567
  <div class="corner br"></div>
568
+ <div class="camara-label">CÁMARA EN VIVO</div>
569
+ <div class="rec-dot"></div>
570
  </div>
571
  </div>
572
 
 
573
  <div class="constantes">
574
  <div class="constante-card">
575
+ <div class="constante-label">// Pulso</div>
576
  <div class="constante-valor" id="pulsoValor">--<span class="constante-unidad">BPM</span></div>
577
  </div>
578
  <div class="constante-card">
579
+ <div class="constante-label">// Oxígeno</div>
580
  <div class="constante-valor" id="oxigenoValor">--<span class="constante-unidad">%</span></div>
581
  </div>
582
  </div>
583
 
 
584
  <div class="sonido-card">
585
+ <div class="sonido-label">// Sonido ambiente</div>
586
  <div class="sonido-barra-container">
587
+ <div class="sonido-barra"><div class="sonido-fill" id="sonidoFill"></div></div>
588
+ <div class="sonido-estado" id="sonidoEstado">INACTIVO</div>
 
 
589
  </div>
590
  </div>
591
 
 
 
 
 
592
  <div class="botones">
593
+ <button class="btn btn-iniciar" id="btnIniciar" onclick="iniciarVigilancia()">▶ INICIAR</button>
594
+ <button class="btn btn-parar" id="btnParar" onclick="pararVigilancia()" disabled>■ PARAR</button>
595
  </div>
596
 
 
597
  <div class="log-box">
598
  <div class="log-label">// Registro de eventos</div>
599
+ <div id="logContainer"></div>
 
 
600
  </div>
 
601
  </div>
602
 
603
  <script>
604
+ const BACKEND = "https://cristobal299-iaweb.hf.space/vigilar";
605
+ let streamActivo = false;
 
 
 
 
606
  let intervalId = null;
607
+ let mediaStream = null;
608
  let audioContext = null;
609
  let analyser = null;
610
+ let alarmaActiva = false;
 
 
611
  let alarmaAudio = null;
612
 
613
+ // ── PEERJS ──────────────────────────────────────────
614
+ let peer = null;
615
+ let peerId = null;
616
+ let llamadasActivas = 0;
617
+
618
+ function iniciarPeer() {
619
+ peer = new Peer({
620
+ host: '0.peerjs.com',
621
+ port: 443,
622
+ secure: true,
623
+ config: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }
624
+ });
625
+
626
+ peer.on('open', id => {
627
+ peerId = id;
628
+ document.getElementById('peerIdText').textContent = id;
629
+ document.getElementById('peerIdBox').style.display = 'block';
630
+ agregarLog('ID PeerJS generado — comparte con el familiar', 'info');
631
+ });
632
+
633
+ peer.on('call', call => {
634
+ if (!mediaStream) {
635
+ agregarLog('Familiar intentó conectar, cámara no activa aún', 'critico');
636
+ return;
637
+ }
638
+ call.answer(mediaStream);
639
+ llamadasActivas++;
640
+ actualizarConectados();
641
+ agregarLog('Familiar conectado a la cámara en vivo ✓', 'info');
642
+
643
+ call.on('close', () => {
644
+ llamadasActivas = Math.max(0, llamadasActivas - 1);
645
+ actualizarConectados();
646
+ agregarLog('Familiar desconectado de la cámara', 'info');
647
+ });
648
+ call.on('error', err => {
649
+ agregarLog('Error en llamada PeerJS: ' + err, 'critico');
650
+ });
651
+ });
652
+
653
+ peer.on('error', err => {
654
+ agregarLog('Error PeerJS: ' + err.type, 'critico');
655
+ });
656
+ }
657
+
658
+ function actualizarConectados() {
659
+ const el = document.getElementById('peerConectados');
660
+ el.textContent = llamadasActivas > 0
661
+ ? `👁 ${llamadasActivas} familiar(es) viendo en vivo`
662
+ : '';
663
+ }
664
+
665
+ function copiarPeerId() {
666
+ if (!peerId) return;
667
+ navigator.clipboard.writeText(peerId)
668
+ .then(() => agregarLog('ID copiado al portapapeles ✓', 'info'))
669
+ .catch(() => {
670
+ // fallback
671
+ const ta = document.createElement('textarea');
672
+ ta.value = peerId;
673
+ document.body.appendChild(ta);
674
+ ta.select();
675
+ document.execCommand('copy');
676
+ document.body.removeChild(ta);
677
+ agregarLog('ID copiado ✓', 'info');
678
+ });
679
+ }
680
+ // ────────────────────────────────────────────────────
681
+
682
  async function iniciarVigilancia() {
683
+ const nombre = document.getElementById('nombreInput').value.trim() || 'Paciente';
684
+
685
  try {
686
+ mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
687
+ document.getElementById('video').srcObject = mediaStream;
 
 
 
 
688
 
689
+ // Análisis de audio
690
+ audioContext = new AudioContext();
691
+ const src = audioContext.createMediaStreamSource(mediaStream);
 
692
  analyser = audioContext.createAnalyser();
693
  analyser.fftSize = 256;
694
+ src.connect(analyser);
 
695
 
696
+ streamActivo = true;
697
  document.getElementById('btnIniciar').disabled = true;
698
  document.getElementById('btnParar').disabled = false;
699
+ agregarLog('Cámara y micrófono iniciados', 'info');
700
+
701
+ // Iniciar PeerJS para compartir cámara
702
+ iniciarPeer();
703
 
704
+ intervalId = setInterval(() => enviarDatos(nombre), 8000);
705
+ enviarDatos(nombre);
 
 
 
 
 
706
 
707
  } catch (err) {
708
+ agregarLog('Error al acceder a cámara/micro: ' + err.message, 'critico');
 
709
  }
710
  }
711
 
 
712
  function pararVigilancia() {
713
+ streamActivo = false;
714
  clearInterval(intervalId);
715
+ if (mediaStream) {
716
+ mediaStream.getTracks().forEach(t => t.stop());
717
+ mediaStream = null;
718
+ }
719
+ if (audioContext) { audioContext.close(); audioContext = null; }
720
+ if (peer) { peer.destroy(); peer = null; peerId = null; }
721
  document.getElementById('video').srcObject = null;
722
+ document.getElementById('peerIdBox').style.display = 'none';
723
  document.getElementById('btnIniciar').disabled = false;
724
  document.getElementById('btnParar').disabled = true;
 
725
  document.getElementById('sonidoFill').style.width = '0%';
726
+ document.getElementById('sonidoEstado').textContent = 'INACTIVO';
 
 
727
  agregarLog('Vigilancia detenida', 'info');
728
  }
729
 
730
+ function obtenerNivelSonido() {
731
+ if (!analyser) return { nivel: 0, texto: 'silencio' };
732
+ const data = new Uint8Array(analyser.frequencyBinCount);
733
+ analyser.getByteFrequencyData(data);
734
+ const avg = data.reduce((a, b) => a + b, 0) / data.length;
735
+ const pct = Math.min(100, (avg / 128) * 100);
736
+ document.getElementById('sonidoFill').style.width = pct + '%';
737
+ let texto = 'silencio';
738
+ if (avg > 60) texto = 'ruido alto';
739
+ else if (avg > 30) texto = 'ruido moderado';
740
+ else if (avg > 10) texto = 'ruido leve';
741
+ document.getElementById('sonidoEstado').textContent = texto.toUpperCase();
742
+ return { nivel: Math.round(pct), texto };
743
+ }
744
+
745
+ async function enviarDatos(nombre) {
746
+ if (!streamActivo) return;
747
+ const sonido = obtenerNivelSonido();
748
+ const pulso = Math.floor(60 + Math.random() * 40);
749
+ const oxigeno = Math.floor(94 + Math.random() * 6);
750
+
751
+ document.getElementById('pulsoValor').innerHTML = pulso + '<span class="constante-unidad">BPM</span>';
752
  document.getElementById('oxigenoValor').innerHTML = oxigeno + '<span class="constante-unidad">%</span>';
753
 
 
 
754
  try {
755
+ const resp = await fetch(BACKEND, {
756
  method: 'POST',
757
  headers: { 'Content-Type': 'application/json' },
758
  body: JSON.stringify({
759
+ nombre,
760
+ pulso,
761
+ oxigeno,
762
+ sonido_ambiente: sonido.texto,
763
+ movimiento_respiracion: 'detectado'
764
  })
765
  });
766
+ const data = await resp.json();
767
+ actualizarEstado(data);
768
+ } catch (e) {
769
+ agregarLog('Error de conexión con servidor', 'critico');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
  }
771
  }
772
 
773
+ function actualizarEstado(data) {
774
+ const estado = (data.estado || 'NORMAL').toUpperCase();
775
+ const box = document.getElementById('estadoBox');
776
+ const valor = document.getElementById('estadoValor');
777
+ const motivo = document.getElementById('motivoTexto');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
 
779
+ box.className = 'estado-box ' + estado.toLowerCase();
780
+ valor.className = 'estado-valor ' + estado.toLowerCase();
781
+ valor.textContent = estado;
782
+ motivo.textContent = data.motivo || '';
 
 
 
 
 
 
 
783
 
784
+ if (estado === 'CRITICO' && !alarmaActiva) disparaAlarma(data.motivo);
785
+ if (estado === 'NORMAL') agregarLog('Estado normal ✓', 'normal');
786
+ if (estado === 'CRITICO') agregarLog('⚠ CRÍTICO: ' + data.motivo, 'critico');
 
 
 
 
 
 
 
 
 
 
 
787
  }
788
 
789
+ function disparaAlarma(motivo) {
790
+ alarmaActiva = true;
791
  document.getElementById('alarmaMotivo').textContent = motivo;
792
  document.getElementById('alarmaOverlay').classList.add('activa');
 
 
793
  try {
794
+ alarmaAudio = new Audio('data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAA==');
795
+ alarmaAudio.loop = true;
796
+ alarmaAudio.play().catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  } catch(e) {}
798
  }
799
 
800
  function silenciarAlarma() {
801
+ alarmaActiva = false;
802
  document.getElementById('alarmaOverlay').classList.remove('activa');
803
+ if (alarmaAudio) { alarmaAudio.pause(); alarmaAudio = null; }
804
  }
805
+
806
+ function agregarLog(msg, tipo = '') {
807
+ const c = document.getElementById('logContainer');
808
+ const el = document.createElement('div');
809
+ el.className = 'log-entry ' + tipo;
810
+ const hora = new Date().toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
811
+ el.textContent = '[' + hora + '] ' + msg;
812
+ c.insertBefore(el, c.firstChild);
813
+ if (c.children.length > 30) c.removeChild(c.lastChild);
814
+ }
815
+
816
+ agregarLog('Sistema listo — pulsa INICIAR', 'info');
817
  </script>
818
  </body>
819
  </html>
index_peer.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // PEERJS - Emisor
2
+ let peer;
3
+ let peerId = null;
4
+
5
+ function iniciarPeer() {
6
+ peer = new Peer({
7
+ host: '0.peerjs.com',
8
+ port: 443,
9
+ secure: true
10
+ });
11
+
12
+ peer.on('open', id => {
13
+ peerId = id;
14
+ document.getElementById('peerIdBox').textContent = 'ID: ' + id;
15
+ document.getElementById('peerIdBox').style.display = 'block';
16
+ });
17
+
18
+ peer.on('call', call => {
19
+ navigator.mediaDevices.getUserMedia({video: true, audio: true})
20
+ .then(stream => {
21
+ call.answer(stream);
22
+ agregarLog('Familiar conectado a la cámara ✓', 'info');
23
+ });
24
+ });
25
+ }