Files
whatsapp/modules/turnero/views/display_global.php
T
Lizandro GuarnizoandClaude Opus 5 b3a6be8f17 Pantalla TV: dejar de cortar la voz que arranca despacio
Dos defectos del mismo cambio de hoy, que explican lo que se oía.

La detección de "el navegador bloqueó el audio" daba 1,9 s de plazo y, si en
ese tiempo no había empezado a hablar, cancelaba y repetía con otra voz. Pero
una voz remota tarda en arrancar porque se baja de internet: el plazo la
declaraba muerta cuando iba a hablar, le cortaba la frase y la repetía con la
voz del navegador. De ahí que se oyera media frase y luego otra distinta, a
veces de hombre y a veces de mujer. Ahora se le pregunta al sintetizador si
está trabajando antes de darla por fallida, y el plazo sube a 3,2 s.

Y la voz se elegía al llegar el turno, no al hablar. La lista de voces la
carga el navegador de forma asíncrona, así que en los primeros llamados tras
abrir la página venía vacía y se hablaba con la voz por defecto. Ahora se
resuelve en el momento de hablar, cuando ya está cargada.

Comprobado: con voz instantánea, lenta (2 s) y muy lenta (4 s) ya no hay
corte y siempre habla Sabina; con el audio bloqueado se sigue avisando para
que el cartel no quede pegado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:08 -05:00

958 lines
37 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;
}
.pg-body.has-video {
grid-template-columns: 1fr 29%;
}
.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 reel ── */
.reel-wrap {
display: none;
height: 100%;
overflow: hidden;
padding: 8px 8px 8px 0;
}
.reel-wrap.has-video { display: block; }
.reel-wrap .reel-inner {
width: 100%; height: 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;
if (_firstLoad) {
_firstLoad = false;
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
if (sorted.length > 0) {
const t = sorted[0];
const c = t.prioridad_color || '<?= $_labColor ?>';
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
announcedCalls.add(key);
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
}
}
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>