Files
whatsapp/modules/turnero/views/display_global.php
T
Lizandro GuarnizoandClaude Opus 5 24c05a40c2 Pantalla TV: el sonido arranca encendido, sin esperar que alguien la toque
sonidoActivo empezaba en false y solo un clic lo encendía, así que cualquier
recarga —corte de red, reinicio, refresco— dejaba el televisor mudo hasta que
una persona fuera físicamente a tocarlo. Peor: con esa lógica ni siquiera
poniendo --autoplay-policy=no-user-gesture-required habría sonado, porque la
página se bloqueaba a sí misma antes de que el navegador opinara.

Ahora arranca encendido e intenta abrir el audio al cargar. Si el navegador
lo permite, habla sin que nadie intervenga. Si lo bloquea, se muestra el
letrero para que alguien toque una vez, que es el único camino que dejan las
políticas de reproducción automática.

El pito de confirmación queda solo para el clic humano: al arrancar sola, la
pantalla no tiene por qué pitar cada vez que se recarga. Y el letrero ya no
depende de localStorage sino de si el audio quedó realmente suspendido,
atado a onstatechange porque resume() es asíncrono y antes parpadeaba.

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

877 lines
34 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;
function getVozES() {
if (_vozES) return _vozES;
const voices = window.speechSynthesis.getVoices();
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voices.find(v => v.lang === lang);
if (v) { _vozES = v; return v; }
}
const v = voices.find(v => v.lang.startsWith('es'));
if (v) { _vozES = v; }
return _vozES;
}
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.
function anunciarTurno(codigo, destino, paciente) {
playBeep();
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
const letras = codigo.split('').join(' ');
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null;
let texto = `Turno ${letras}`;
if (nomVoz) texto += `, ${nomVoz}`;
texto += `, pase a ${destino}`;
const utt = new SpeechSynthesisUtterance(texto);
utt.lang = 'es-CO';
const voz = getVozES();
if (voz) utt.voice = voz;
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
// Se espera a que el pito termine (dura 0,65 s) antes de hablar. Hablando
// encima, el tono tapaba "Turno X" y desde lejos parecía que el anuncio
// empezaba ya empezado.
const BEEP_MS = 700;
// 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(() => window.speechSynthesis.speak(utt), BEEP_MS);
return utt;
}
/* ── 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');
const utt = anunciarTurno(codigo, destino, paciente);
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)));
};
if (utt) {
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400));
utt.addEventListener('error', cerrarCuandoToque);
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo
setTimeout(dismiss, 25000);
} 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>