Files
whatsapp/modules/turnero/views/display_global.php
Lizandro GuarnizoandClaude Opus 5 c7ff8e4ead Pantalla TV: el video pasa de formato reel a cuadrado de verdad
El cambio anterior hizo cuadrada la caja, pero la dejó dentro de la columna
fija del 29% —que es proporción de reel—, así que el cuadrado salía diminuto
y con medio espacio vertical vacío.

Ahora la columna se dimensiona sola (grid `auto`) y el cuadrado toma todo el
alto del cuerpo, deduciendo el ancho de ahí. Queda tan grande como quepa, con
tope de media pantalla para no ahogar la columna de llamados.

El aspect-ratio va en el elemento interno y no en el contenedor: con
box-sizing border-box, el padding asimétrico del contenedor deformaba la
proporción y el "cuadrado" no quedaba cuadrado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:22:10 -05:00

973 lines
38 KiB
PHP

<?php
$_dispCfg = [];
try {
$__pdo = Database::getInstance()->getConnection();
$__rows = $__pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$_dispCfg = $__rows ?: [];
} catch (\Throwable $_) {}
$_labNombre = htmlspecialchars($_dispCfg['empresa_nombre'] ?? 'Sistema de Turnos');
$_labLogo = $_dispCfg['doc_logo_base64'] ?? '';
$_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '') ? $_dispCfg['doc_color'] : '#1565c0';
$_tvPlaylist = [];
try {
$_tvPlaylist = $__pdo->query(
"SELECT tipo, url, duracion_segundos FROM turnero_tv_media WHERE activo = 1 ORDER BY orden ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $_) {}
$_hasVideo = (bool)$_tvPlaylist;
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pantalla Global de Turnos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; width: 100%;
overflow: hidden;
background: #f8fafc;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #1e293b;
}
:root {
--brand: <?= $_labColor ?>;
--brand-glow: color-mix(in srgb, <?= $_labColor ?> 70%, transparent);
}
.pg-wrap {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
overflow: hidden;
}
/* ── Header ── */
.pg-header {
display: flex; align-items: center; gap: 1rem;
padding: .7rem 1.8rem;
background: #fff;
border-bottom: 3px solid var(--brand);
flex-shrink: 0;
}
.hd-brand { display: flex; align-items: center; gap: .7rem; flex: 1; min-width: 0; }
.hd-brand .logo {
height: 44px; max-width: 110px; object-fit: contain;
background: #f1f5f9; border-radius: 8px; padding: 4px 7px;
}
.lab-nombre {
font-size: clamp(1.5rem, 2.6vw, 1.85rem);
font-weight: 700; color: #1e293b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hd-right { display: flex; align-items: center; gap: .8rem; flex-shrink: 0; }
.reloj {
font-size: clamp(1.7rem, 3.1vw, 2.15rem);
font-weight: 700; color: #64748b;
font-variant-numeric: tabular-nums; letter-spacing: .5px;
}
.live-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #4ade80; box-shadow: 0 0 8px #4ade80;
animation: pdot 2s ease-in-out infinite;
}
@keyframes pdot { 0%,100%{opacity:1} 50%{opacity:.3} }
.btn-fs {
background: transparent; border: 1px solid #e2e8f0;
color: #64748b; border-radius: 8px; padding: 6px 10px;
font-size: 1.1rem; cursor: pointer; font-family: inherit;
transition: background .15s;
}
.btn-fs:hover { background: #f1f5f9; }
.btn-fs.on { background: #dcfce7; border-color: #86efac; color: #15803d; }
/* ── Background decoration ── */
.bg-deco {
position: fixed; inset: 0;
pointer-events: none; z-index: 0;
overflow: hidden;
}
.bg-deco .dot {
position: absolute;
border-radius: 50%;
background: var(--brand);
opacity: .04;
}
.bg-deco .dot:nth-child(1) { width: 300px; height: 300px; top: -80px; left: -60px; }
.bg-deco .dot:nth-child(2) { width: 180px; height: 180px; bottom: 10%; left: 15%; }
.bg-deco .dot:nth-child(3) { width: 120px; height: 120px; top: 30%; left: 60%; }
.bg-deco .dot:nth-child(4) { width: 240px; height: 240px; bottom: -60px; right: 30%; }
.bg-deco .dot:nth-child(5) { width: 80px; height: 80px; top: 15%; right: 25%; }
/* ── Main body ── */
.pg-body {
display: grid;
grid-template-columns: 1fr;
overflow: hidden;
position: relative;
}
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
pida su contenido, y el contenido es un cuadrado tan alto como el
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
.pg-body.has-video {
grid-template-columns: 1fr auto;
}
.pg-body.has-video .turno-activo { display: none; }
/* ── Turno activo (centro) ── */
.turno-activo {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
padding: 1rem;
position: relative;
overflow: hidden;
flex: 1;
}
.turno-activo .label {
font-size: clamp(.69rem, 1.07vw, .82rem);
font-weight: 700; text-transform: uppercase; letter-spacing: 4px;
color: #94a3b8;
margin-bottom: .2rem;
}
.turno-activo .codigo {
font-size: clamp(1.89rem, 8.19vw, 5.67rem);
font-weight: 900; line-height: 1;
letter-spacing: -4px;
color: var(--brand);
transition: color .3s;
font-variant-numeric: tabular-nums;
position: relative;
z-index: 1;
}
.pulse-ring {
position: absolute;
top: 50%; left: 50%;
width: 90vmin; height: 90vmin;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3px solid var(--brand);
opacity: 0;
pointer-events: none;
z-index: 0;
transition: opacity .6s ease;
}
.pulse-ring.active {
opacity: 1;
animation: pulse-ring 3s ease-in-out infinite;
}
@keyframes pulse-ring {
0% { transform: translate(-50%, -50%) scale(.75); opacity: .15; }
50% { transform: translate(-50%, -50%) scale(1.1); opacity: .08; }
100% { transform: translate(-50%, -50%) scale(.75); opacity: .15; }
}
.turno-activo .lugar-label {
font-size: clamp(.95rem, 1.89vw, 1.58rem);
font-weight: 600;
color: #94a3b8;
margin-top: .3rem;
}
.turno-activo .lugar-nombre {
font-size: clamp(.63rem, 5.04vw, 3.78rem);
font-weight: 800;
color: #1e293b;
margin-top: .1rem;
}
.turno-activo .pac-nombre {
font-size: clamp(1.67rem, 2.27vw, 1.86rem);
color: #64748b;
margin-top: .3rem;
}
.turno-activo .sin-turno {
font-size: clamp(.92rem, 1.76vw, 1.30rem);
color: #cbd5e1;
}
/* ── Últimos llamados (columna izquierda) ── */
.left-area {
display: flex; flex-direction: column;
overflow: hidden;
position: relative;
background: color-mix(in srgb, var(--brand) 6%, #f8fafc);
border-right: 1px solid #e2e8f0;
}
.ultimos-llamados {
display: flex; flex-direction: column;
overflow: hidden;
height: 100%;
}
.ultimos-llamados .ul-label {
font-size: 1rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 2px; color: #94a3b8;
padding: .65rem .85rem .3rem; flex-shrink: 0;
}
.ul-thead {
display: grid; grid-template-columns: 160px 1fr;
padding: .35rem 1rem;
background: color-mix(in srgb, var(--brand) 12%, #f1f5f9);
border-top: 1px solid #e2e8f0; border-bottom: 2px solid #e2e8f0;
font-size: 1.75rem; font-weight: 800;
text-transform: uppercase; letter-spacing: .1em; color: #64748b;
flex-shrink: 0;
}
.ultimos-llamados .ul-scroll {
flex: 1; overflow-y: auto; padding: .35rem .5rem .5rem;
scrollbar-width: thin; scrollbar-color: #e2e8f0 transparent;
}
.ul-item {
display: grid; grid-template-columns: 160px 1fr;
align-items: center;
padding: .5rem .65rem; border-radius: 10px;
margin-bottom: .35rem;
background: #fff; border: 1px solid #e2e8f0;
border-left: 4px solid color-mix(in srgb, var(--brand, #1565c0) 55%, transparent);
}
.ul-item.activo {
background: var(--brand);
border-color: var(--brand);
border-left-color: rgba(255,255,255,.4);
color: #fff;
}
.ul-item .ul-cod { font-weight: 800; font-size: 2.47rem; line-height: 1; }
.ul-item .ul-dest {
font-size: 2.05rem; font-weight: 700; color: #1e293b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
line-height: 1.1;
}
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
/* ── Video cuadrado ──
El material ya no es de proporción reel sino cuadrado. La caja toma
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
de la pantalla para no ahogar la columna de los llamados. */
.reel-wrap {
display: none;
height: 100%;
max-width: 50vw;
overflow: hidden;
padding: 8px 8px 8px 0;
align-items: center;
justify-content: center;
}
.reel-wrap.has-video { display: flex; }
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
border-box, el padding asimétrico del contenedor deformaría la
proporción. La columna se ajusta sola al ancho que resulte. */
.reel-wrap .reel-inner {
height: 100%;
width: auto;
aspect-ratio: 1 / 1;
max-width: 100%;
border-radius: 16px;
overflow: hidden;
box-shadow: -8px 0 30px rgba(0,0,0,.08);
}
.reel-wrap video {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
/* ── Waiting queue ── */
.pg-cola {
background: #fff;
border-top: 1px solid #e2e8f0;
padding: .35rem 1rem;
display: flex;
align-items: center;
gap: .5rem;
overflow-x: auto;
flex-shrink: 0;
scrollbar-width: none;
min-height: 44px;
}
.pg-cola::-webkit-scrollbar { display: none; }
.cola-lbl {
font-size: .85rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 1.5px;
color: #94a3b8; white-space: nowrap; flex-shrink: 0;
}
.cola-sep { width: 1px; height: 18px; background: #e2e8f0; flex-shrink: 0; }
.cola-chip {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .6rem; border-radius: 99px;
font-size: .97rem; font-weight: 700;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #475569;
white-space: nowrap; flex-shrink: 0;
}
.cola-chip .cdot { width: 5px; height: 5px; border-radius: 50%; display: inline-block; }
.cola-empty { font-size: .9rem; color: #cbd5e1; }
/* ── Announcement overlay ── */
#ann-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .35s ease;
}
#ann-overlay.visible { opacity: 1; pointer-events: all; }
.ann-bg {
position: absolute; inset: 0;
background: rgba(4,8,20,.94);
backdrop-filter: blur(20px);
}
.ann-card {
position: relative; z-index: 1;
text-align: center;
transform: scale(.85) translateY(20px);
transition: transform .45s cubic-bezier(.34,1.5,.64,1);
}
#ann-overlay.visible .ann-card { transform: scale(1) translateY(0); }
.ann-label {
font-size: clamp(1.05rem, 1.8vw, 1.25rem); font-weight: 700;
text-transform: uppercase; letter-spacing: 4px;
color: rgba(255,255,255,.3); margin-bottom: .2rem;
}
.ann-code {
font-size: clamp(7rem, 22vw, 16rem); font-weight: 900;
line-height: 1; letter-spacing: -6px;
font-variant-numeric: tabular-nums;
color: var(--ac, var(--brand));
text-shadow: 0 0 80px var(--ac, var(--brand))66;
}
.ann-destino {
font-size: clamp(1.75rem, 4.3vw, 3.25rem); font-weight: 700;
color: #fff; margin-top: .2rem;
}
.ann-pac {
font-size: clamp(2rem, 4vw, 3rem);
color: rgba(255,255,255,.85); margin-top: .3rem;
}
.ann-progress {
width: 200px; height: 3px;
background: rgba(255,255,255,.06);
border-radius: 99px; margin: 1.5rem auto 0; overflow: hidden;
}
.ann-bar {
height: 100%; width: 100%; border-radius: 99px;
background: var(--ac, var(--brand));
box-shadow: 0 0 10px var(--ac, var(--brand));
}
/* ── Mini stat en footer ── */
.pg-footer {
position: fixed;
bottom: 0; left: 0; right: 0;
display: flex; justify-content: center; align-items: center;
gap: 1.5rem;
padding: .25rem 1rem;
background: #fff;
border-top: 1px solid #e2e8f0;
flex-shrink: 0;
z-index: 50;
}
.stat-item { text-align: center; }
.stat-item .val {
font-size: clamp(.95rem, 1.5vw, 1.1rem);
font-weight: 700; color: #475569;
font-variant-numeric: tabular-nums;
}
.stat-item .lbl {
font-size: .75rem; color: #94a3b8;
text-transform: uppercase; letter-spacing: 1px;
}
.stat-sep { width: 1px; height: 18px; background: #e2e8f0; }
</style>
</head>
<body>
<div class="bg-deco">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
</div>
<div id="ann-overlay">
<div class="ann-bg"></div>
<div class="ann-card">
<div class="ann-label">Pase a</div>
<div class="ann-code" id="ann-code">A01</div>
<div class="ann-destino" id="ann-destino">Recepción 1</div>
<div class="ann-pac" id="ann-pac"></div>
<div class="ann-progress"><div class="ann-bar" id="ann-bar"></div></div>
</div>
</div>
<div class="pg-wrap">
<header class="pg-header">
<div class="hd-brand">
<?php if ($_labLogo): ?>
<img class="logo" src="<?= htmlspecialchars($_labLogo) ?>" alt="Logo">
<?php endif; ?>
<div class="lab-nombre"><?= $_labNombre ?></div>
</div>
<div class="hd-right">
<div class="live-dot"></div>
<div class="reloj" id="reloj">--:--:--</div>
<button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
<i class="fas fa-volume-mute"></i>
</button>
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
<i class="fas fa-expand" id="fs-icon"></i>
</button>
</div>
</header>
<div class="pg-body <?= $_hasVideo ? 'has-video' : '' ?>">
<div class="left-area">
<div class="ultimos-llamados" id="ultimos-llamados" style="display:none">
<div class="ul-label"><i class="fas fa-history me-1"></i>Llamados</div>
<div class="ul-thead">
<span>Turno</span>
<span>Lugar</span>
</div>
<div class="ul-scroll" id="ul-scroll"></div>
</div>
</div>
<div class="turno-activo" id="zona-activo">
<div class="pulse-ring" id="pulse-ring"></div>
<div class="label">Siguiente turno</div>
<div class="codigo" id="codigo-activo">—</div>
<div class="lugar-label" id="lugar-label">Llamado a</div>
<div class="lugar-nombre" id="lugar-nombre">—</div>
<div class="pac-nombre" id="pac-nombre"></div>
</div>
<div class="reel-wrap <?= $_hasVideo ? 'has-video' : '' ?>" id="reel-wrap">
<div class="reel-inner" id="tv-playlist"></div>
</div>
</div>
<div class="pg-cola" id="pg-cola">
<span class="cola-lbl">EN ESPERA</span>
<div class="cola-sep"></div>
<span class="cola-empty" id="cola-vacia">Cola vacía</span>
</div>
</div>
<footer class="pg-footer">
<div class="stat-item"><div class="val" id="st-espera">—</div><div class="lbl">Espera</div></div>
<div class="stat-sep"></div>
<div class="stat-item"><div class="val" id="st-atencion">—</div><div class="lbl">Atención</div></div>
<div class="stat-sep"></div>
<div class="stat-item"><div class="val" id="st-finalizados">—</div><div class="lbl">Atendidos</div></div>
<div class="stat-sep"></div>
<div class="stat-item"><div class="val" id="st-total">—</div><div class="lbl">Total día</div></div>
</footer>
<script>
const BASE_API = '<?= BASE_URL ?>modules/turnero/api/';
/* ── Playlist TV (videos e imágenes en bucle) ── */
const TV_PLAYLIST = <?= json_encode($_tvPlaylist, JSON_UNESCAPED_UNICODE) ?>;
(function () {
const wrap = document.getElementById('tv-playlist');
if (!wrap || !TV_PLAYLIST.length) return;
let idx = 0, imgTimer = null;
function mostrar(i) {
clearTimeout(imgTimer);
wrap.innerHTML = '';
const item = TV_PLAYLIST[i];
if (item.tipo === 'video') {
const v = document.createElement('video');
v.src = item.url; v.autoplay = true; v.muted = true; v.playsInline = true;
v.addEventListener('ended', siguiente);
v.addEventListener('error', siguiente);
wrap.appendChild(v);
} else {
const img = document.createElement('img');
img.src = item.url;
img.style.cssText = 'width:100%;height:100%;object-fit:cover';
wrap.appendChild(img);
imgTimer = setTimeout(siguiente, Math.max(1, item.duracion_segundos || 8) * 1000);
}
}
function siguiente() {
idx = (idx + 1) % TV_PLAYLIST.length;
mostrar(idx);
}
mostrar(idx);
})();
function tick() {
const d = new Date();
document.getElementById('reloj').textContent =
String(d.getHours()).padStart(2,'0') + ':' +
String(d.getMinutes()).padStart(2,'0') + ':' +
String(d.getSeconds()).padStart(2,'0');
}
tick(); setInterval(tick, 1000);
/* ── Audio ── */
// Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
let audioCtx = null, sonidoActivo = true;
function activarSonido(porGesto) {
try {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
// porque el estado todavía decía "suspended" cuando ya iba a sonar.
audioCtx.onstatechange = revisarBloqueo;
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + 0.05);
} catch (_) {}
sonidoActivo = true;
localStorage.setItem('turneroSonido', '1');
const btn = document.getElementById('btn-sonido');
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
// pantalla no tiene por qué pitar cada vez que se recarga.
if (porGesto) setTimeout(playBeep, 100);
revisarBloqueo();
}
/**
* El navegador puede negarse a sonar sin un gesto humano. No hay forma de
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
* se muestra el letrero para que alguien toque la pantalla una vez.
*
* Se evita del todo abriendo Chrome en el televisor con
* --autoplay-policy=no-user-gesture-required
*/
function revisarBloqueo() {
const banner = document.getElementById('sonido-banner');
if (!banner) return;
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
banner.style.display = bloqueado ? 'flex' : 'none';
}
// Intento de arranque automático, en cuanto la página está lista
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
} else {
activarSonido(false);
}
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
document.addEventListener('click', () => activarSonido(true));
document.addEventListener('touchstart', () => activarSonido(true));
function playBeep() {
if (!sonidoActivo || !audioCtx) return;
if (audioCtx.state === 'suspended') { audioCtx.resume().then(playBeep); return; }
try {
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'sine';
osc.connect(g); g.connect(audioCtx.destination);
osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.setValueAtTime(1100, audioCtx.currentTime + 0.1);
osc.frequency.setValueAtTime(880, audioCtx.currentTime + 0.2);
g.gain.setValueAtTime(0.35, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.65);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.65);
} catch (_) {}
}
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
let _vozES = null;
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
// internet en cada llamado—, y para leer letras, números y un nombre propio
// el acento mexicano se oye natural aquí; el de España no.
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
function getVozES() {
if (_vozES) return _vozES;
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
if (!voces.length) return null;
const locales = voces.filter(v => v.localService);
const elegir = v => { _vozES = v; return v; };
// 1. Las pedidas por nombre, si están instaladas
for (const nombre of VOCES_PREFERIDAS) {
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
if (v) return elegir(v);
}
// 2. Cualquier otra local latinoamericana
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
const v = locales.find(v => v.lang === lang);
if (v) return elegir(v);
}
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
if (locales.length) return elegir(locales[0]);
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
// es la que venía entrecortándose.
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return elegir(v);
}
return elegir(voces[0]);
}
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
// troceaba siempre.
setInterval(() => {
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
window.speechSynthesis.pause();
window.speechSynthesis.resume();
}, 10000);
}
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
// A nivel de archivo porque showAnnouncement() también lo necesita.
const BEEP_MS = 700;
/**
* Dice el llamado en voz alta.
*
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
* señal, y si nunca llegaba se quedaba pegado en pantalla.
*
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
*
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
*/
function anunciarTurno(codigo, destino, paciente, onFin) {
playBeep();
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
const letras = codigo.split('').join(' ');
// \w no cuenta las letras acentuadas, así que trataba la tilde como
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
const nomVoz = paciente
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
: null;
let texto = `Turno ${letras}`;
if (nomVoz) texto += `, ${nomVoz}`;
texto += `, pase a ${destino}`;
let avisado = false;
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
const decir = (esReintento) => {
const utt = new SpeechSynthesisUtterance(texto);
utt.lang = 'es-CO';
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
let arranco = false;
utt.addEventListener('start', () => { arranco = true; });
utt.addEventListener('end', avisar);
utt.addEventListener('error', () => esReintento ? avisar() : decir(true));
// Que speak() no lance nada no significa que vaya a sonar: si el
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
setTimeout(() => {
if (arranco) return;
// Antes de darla por fallida hay que preguntarle al sintetizador:
// una voz remota tarda en arrancar porque se baja de internet, y
// cancelarla aquí era cortarle la frase y repetirla con otra voz.
// De ahí que a veces se oyera media frase y luego otra distinta.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
if (esReintento) { avisar(); return; }
window.speechSynthesis.cancel();
decir(true); // segunda oportunidad, con la voz del navegador
}, margen);
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
// speak() encadenado a un cancel() en el mismo ciclo.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
window.speechSynthesis.cancel();
}
setTimeout(() => {
// La voz se elige aquí y no al crear el anuncio: el navegador carga
// la lista de forma asíncrona, y en los primeros llamados tras abrir
// la página todavía venía vacía. Entonces no se asignaba voz y
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
// veces una mujer—. A esta altura ya está cargada.
if (!esReintento) {
const voz = getVozES();
if (voz) utt.voice = voz;
}
window.speechSynthesis.speak(utt);
}, esReintento ? 0 : BEEP_MS);
};
decir(false);
return true;
}
/* ── Fullscreen ── */
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
document.addEventListener('fullscreenchange', () => {
const ico = document.getElementById('fs-icon');
if (ico) ico.className = document.fullscreenElement ? 'fas fa-compress' : 'fas fa-expand';
});
/* ── Announcement ── */
const annQueue = [];
let isAnnouncing = false;
function queueAnnouncement(item) {
if (annQueue.some(q => q._key === item._key)) return;
annQueue.push(item);
if (!isAnnouncing) processQueue();
}
function processQueue() {
if (annQueue.length === 0) { isAnnouncing = false; return; }
isAnnouncing = true;
showAnnouncement(annQueue.shift());
}
function showAnnouncement({ codigo, destino, paciente, color }) {
const ov = document.getElementById('ann-overlay');
const cd = document.getElementById('ann-code');
const dst = document.getElementById('ann-destino');
const pac = document.getElementById('ann-pac');
const bar = document.getElementById('ann-bar');
const c = color || '<?= $_labColor ?>';
cd.textContent = codigo;
dst.textContent = destino;
pac.textContent = paciente || '';
ov.style.setProperty('--ac', c);
ov.classList.add('visible');
bar.style.transition = 'none';
bar.style.width = '100%';
requestAnimationFrame(() => requestAnimationFrame(() => {
bar.style.transition = 'width 5s linear';
bar.style.width = '0%';
}));
let dismissed = false;
function dismiss() {
if (dismissed) return;
dismissed = true;
ov.classList.remove('visible');
setTimeout(processQueue, 370);
}
// El cartel se cierra cuando terminó de hablar Y se cumplió el mínimo
// visual. Cerrar a los 5 s sin esperar la voz hacía que el siguiente
// llamado cancelara al anterior a media frase.
const MIN_VISIBLE = 5000;
const abiertoEn = Date.now();
const cerrarCuandoToque = () => {
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
};
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
// pantallas donde el navegador bloquea el audio.
const intentoHablar = anunciarTurno(codigo, destino, paciente,
() => setTimeout(cerrarCuandoToque, 400));
if (intentoHablar) {
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
setTimeout(dismiss, 20000);
} else {
setTimeout(dismiss, MIN_VISIBLE);
}
}
/* ── State ── */
let lastSeenKeys = new Set();
let announcedCalls = new Set(); // dedup por codigo+destino+llamado_at; usar key (no id) permite re-llamadas
let llamados = [];
let _firstLoad = true;
function renderSnapshot(snap) {
const codEl = document.getElementById('codigo-activo');
const lugEl = document.getElementById('lugar-nombre');
const pacEl = document.getElementById('pac-nombre');
const lblEl = document.getElementById('lugar-label');
const ringEl = document.getElementById('pulse-ring');
// Recopilar TODOS los turnos activos: recepción + lugares
const allTurnos = [];
(snap.activos_recepcion || []).forEach(d => {
if (d.turno) allTurnos.push({ ...d.turno, destino: d.desk_nombre });
});
(snap.lugares || []).forEach(l => {
if (l.turno) allTurnos.push({ ...l.turno, destino: l.lugar_nombre });
});
// Eliminar de llamados los turnos que ya no están activos
const activeIds = new Set(allTurnos.map(t => t.id));
llamados = llamados.filter(l => activeIds.has(l.id));
// Detectar turnos nuevos y anunciar
const currentKeys = new Set();
let anuncioNuevo = false;
allTurnos.forEach(t => {
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
currentKeys.add(key);
if (!lastSeenKeys.has(key)) {
const c = t.prioridad_color || '<?= $_labColor ?>';
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
if (!announcedCalls.has(key)) {
announcedCalls.add(key);
if (!_firstLoad) {
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
}
llamados = llamados.filter(l => l.id !== t.id);
llamados.unshift({ id: t.id, codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
if (llamados.length > 30) llamados.pop();
anuncioNuevo = true;
}
}
});
lastSeenKeys = currentKeys;
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
//
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
if (_firstLoad) _firstLoad = false;
if (anuncioNuevo) renderLlamados();
// zona-activo: mostrar el turno llamado más recientemente
const sorted = allTurnos.slice().sort((a, b) => {
return new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0);
});
if (sorted.length > 0) {
const t = sorted[0];
codEl.textContent = t.codigo;
lugEl.textContent = t.destino;
pacEl.textContent = t.paciente_nombre || '';
lblEl.textContent = 'Llamado a';
ringEl.classList.add('active');
const c = t.prioridad_color || '<?= $_labColor ?>';
codEl.style.color = c;
document.getElementById('zona-activo').style.setProperty('--brand', c);
} else {
codEl.textContent = '—';
lugEl.textContent = '—';
pacEl.textContent = '';
lblEl.textContent = 'Llamado a';
codEl.style.color = '<?= $_labColor ?>';
ringEl.classList.remove('active');
}
// Queue
const cola = snap.cola || [];
const pgCola = document.getElementById('pg-cola');
pgCola.querySelectorAll('.cola-chip').forEach(c => c.remove());
const vaciaMg = document.getElementById('cola-vacia');
if (cola.length === 0) {
vaciaMg.style.display = '';
} else {
vaciaMg.style.display = 'none';
cola.forEach(t => {
const chip = document.createElement('span');
const c = t.prioridad_color || '<?= $_labColor ?>';
chip.className = 'cola-chip';
chip.innerHTML = '<span class="cdot" style="background:' + c + ';box-shadow:0 0 4px ' + c + '88"></span>' + esc(t.codigo);
pgCola.appendChild(chip);
});
}
renderLlamados();
// Stats
const s = snap.stats || {};
document.getElementById('st-espera').textContent = s.en_espera || 0;
document.getElementById('st-atencion').textContent = s.en_atencion || 0;
document.getElementById('st-finalizados').textContent = s.finalizados || 0;
document.getElementById('st-total').textContent = s.total || 0;
}
function renderLlamados() {
const wrap = document.getElementById('ultimos-llamados');
const sc = document.getElementById('ul-scroll');
if (!wrap || !sc) return;
if (llamados.length === 0) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
sc.innerHTML = llamados.map((l, i) => {
const isActivo = i === 0;
const c = l.color || '<?= $_labColor ?>';
return `<div class="ul-item${isActivo ? ' activo' : ''}"${isActivo ? ` style="--brand:${c}"` : ''}>
<span class="ul-cod">${esc(l.codigo)}</span>
<span class="ul-dest">${esc(l.destino)}</span>
</div>`;
}).join('');
}
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(s ?? '')));
return d.innerHTML;
}
async function cargarSnapshot() {
try {
const res = await fetch(BASE_API + 'get_display_global.php', { cache: 'no-store' });
const json = await res.json();
if (json.ok) renderSnapshot(json);
} catch(_) {}
}
cargarSnapshot();
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
setInterval(cargarSnapshot, 1000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
window.addEventListener('focus', cargarSnapshot);
if ('wakeLock' in navigator) {
async function pedirWakeLock() {
try { await navigator.wakeLock.request('screen'); } catch (_) {}
}
pedirWakeLock();
document.addEventListener('visibilitychange', () => { if (!document.hidden) pedirWakeLock(); });
}
</script>
</body>
</html>