Pantalla TV del turnero: playlist de videos e imágenes en vez de un solo video

Nueva tabla turnero_tv_media (tipo, url, orden, duracion_segundos) para
soportar múltiples videos/imágenes reproducidos en secuencia y en bucle.
El video que ya estaba cargado se migró como primer ítem de la playlist.

- save_tv_media.php / delete_tv_media.php / reorder_tv_media.php
  reemplazan a save_tv_video.php (eliminado).
- configuracion.php (tab TV): subida múltiple, lista reordenable por
  arrastre, duración editable por imagen.
- display_global.php: el <video> único se reemplaza por un reproductor
  JS que recorre la playlist — video hasta 'ended', imagen por su
  duración configurada, y vuelve a empezar al terminar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-03 14:33:05 -05:00
co-authored by Claude Sonnet 4.6
parent 2ba13add34
commit 6254e45e8f
6 changed files with 352 additions and 189 deletions
+129 -53
View File
@@ -103,6 +103,13 @@ try {
foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; }
} catch (\Throwable $e) { $_loadErrors[] = 'lab_config: ' . $e->getMessage(); }
$tvMedia = [];
try {
$tvMedia = $pdo->query(
"SELECT id, tipo, url, orden, duracion_segundos FROM turnero_tv_media ORDER BY orden ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $e) { $_loadErrors[] = 'turnero_tv_media: ' . $e->getMessage(); }
$plantillasAprobadas = [];
$plantillasHabilitadas = [];
try {
@@ -1140,36 +1147,51 @@ $tab = $_GET['tab'] ?? 'lugares';
TAB 5 — PANTALLA TV
════════════════════════════════════════ -->
<?php elseif ($tab === 'tv'): ?>
<?php $tvVideo = $cfg['turnero_tv_video'] ?? ''; ?>
<p class="section-title"><i class="fas fa-film me-1"></i>Video de fondo / publicidad</p>
<p class="text-muted" style="font-size:.85rem">El video se reproduce en bucle y sin sonido como fondo en la pantalla TV del turnero. Formatos admitidos: MP4, WebM. Máximo 500 MB.</p>
<p class="section-title"><i class="fas fa-film me-1"></i>Playlist de la Pantalla TV</p>
<p class="text-muted" style="font-size:.85rem">Videos e imágenes se reproducen en bucle, uno detrás de otro, sin sonido, como fondo en la pantalla TV del turnero. Formatos: MP4, WebM, JPG, PNG, WEBP — máximo 500 MB por archivo.</p>
<!-- Vista previa actual -->
<div id="tv-preview-wrap" class="mb-4" <?= $tvVideo ? '' : 'style="display:none"' ?>>
<p class="fw-semibold small mb-2">Video actual:</p>
<video id="tv-preview" controls muted
style="max-width:480px;width:100%;border-radius:10px;border:1px solid #e2e8f0"
src="<?= htmlspecialchars($tvVideo) ?>"></video>
<div class="mt-2">
<button class="btn btn-outline-danger btn-sm" onclick="borrarTvVideo()">
<i class="fas fa-trash me-1"></i>Quitar video
<!-- Lista actual -->
<div id="tv-media-list" class="mb-3 d-flex flex-column gap-2">
<?php foreach ($tvMedia as $m): ?>
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
style="border:1px solid #e2e8f0;border-radius:10px">
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
<?php if ($m['tipo'] === 'video'): ?>
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video>
<?php else: ?>
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt="">
<?php endif; ?>
<div class="flex-grow-1">
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
<div class="text-muted small text-truncate" style="max-width:300px"><?= htmlspecialchars(basename($m['url'])) ?></div>
</div>
<?php if ($m['tipo'] === 'imagen'): ?>
<div class="d-flex align-items-center gap-1">
<input type="number" min="1" class="form-control form-control-sm tv-media-dur"
style="width:70px" value="<?= (int)$m['duracion_segundos'] ?>">
<span class="small text-muted">seg</span>
</div>
<?php endif; ?>
<button class="btn btn-outline-danger btn-sm" onclick="borrarTvMedia(<?= (int)$m['id'] ?>)">
<i class="fas fa-trash"></i>
</button>
</div>
<?php endforeach; ?>
<?php if (!$tvMedia): ?>
<div class="text-muted small" id="tv-media-empty">Sin videos ni imágenes en la playlist.</div>
<?php endif; ?>
</div>
<!-- Zona de subida -->
<div id="tv-upload-wrap" <?= $tvVideo ? 'style="display:none"' : '' ?>>
<label class="drop-zone" id="tv-drop" for="tv-file-input"
style="display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:.6rem;padding:2.5rem 1rem;border:2px dashed #cbd5e1;border-radius:12px;
cursor:pointer;text-align:center;transition:border-color .2s">
<i class="fas fa-cloud-upload-alt fa-2x text-primary"></i>
<span class="fw-semibold text-primary">Haz clic o arrastra tu video aquí</span>
<span class="text-muted small">MP4 / WebM — máx 500 MB</span>
</label>
<input type="file" id="tv-file-input" accept="video/mp4,video/webm" class="d-none">
<div class="mb-3">
<button class="btn btn-outline-primary btn-sm" onclick="document.getElementById('tv-file-input').click()">
<i class="fas fa-plus me-1"></i>Agregar video o imagen
</button>
<button class="btn btn-primary btn-sm" onclick="guardarOrdenTv()">
<i class="fas fa-save me-1"></i>Guardar orden / duración
</button>
</div>
<input type="file" id="tv-file-input" accept="video/mp4,video/webm,image/jpeg,image/png,image/webp" class="d-none" multiple>
<!-- Barra de progreso -->
<div id="tv-progress-wrap" class="mt-3" style="display:none">
@@ -1584,28 +1606,38 @@ async function cambiarSesion(accion, sesionId) {
}
// ─────────────────────────────────────────────────────────────
// TAB 5: PANTALLA TV
// TAB 5: PANTALLA TV — playlist de videos e imágenes
// ─────────────────────────────────────────────────────────────
(function () {
const inp = document.getElementById('tv-file-input');
const drop = document.getElementById('tv-drop');
const inp = document.getElementById('tv-file-input');
if (!inp) return;
inp.addEventListener('change', () => {
Array.from(inp.files).forEach(subirTvMedia);
inp.value = '';
});
// Drag-and-drop visual
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = '#3b82f6'; });
drop.addEventListener('dragleave', () => { drop.style.borderColor = '#cbd5e1'; });
drop.addEventListener('drop', e => {
e.preventDefault(); drop.style.borderColor = '#cbd5e1';
const f = e.dataTransfer.files[0];
if (f) subirTvVideo(f);
// Reordenar arrastrando (drag & drop nativo, sin librerías)
const list = document.getElementById('tv-media-list');
let dragEl = null;
list.addEventListener('dragstart', e => {
dragEl = e.target.closest('.tv-media-item');
e.dataTransfer.effectAllowed = 'move';
});
list.querySelectorAll('.tv-media-item').forEach(it => it.setAttribute('draggable', 'true'));
list.addEventListener('dragover', e => {
e.preventDefault();
const target = e.target.closest('.tv-media-item');
if (!target || target === dragEl) return;
const rect = target.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
list.insertBefore(dragEl, after ? target.nextSibling : target);
});
inp.addEventListener('change', () => { if (inp.files[0]) subirTvVideo(inp.files[0]); });
})();
function subirTvVideo(file) {
const allowedMime = ['video/mp4', 'video/webm', 'video/ogg'];
if (!allowedMime.includes(file.type)) { toast('Solo se permiten MP4 o WebM', 'error'); return; }
if (file.size > 500 * 1024 * 1024) { toast('El video supera el límite de 500 MB', 'error'); return; }
function subirTvMedia(file) {
const allowedMime = ['video/mp4', 'video/webm', 'video/ogg', 'image/jpeg', 'image/png', 'image/webp'];
if (!allowedMime.includes(file.type)) { toast(file.name + ': formato no admitido', 'error'); return; }
if (file.size > 500 * 1024 * 1024) { toast(file.name + ': supera el límite de 500 MB', 'error'); return; }
const wrap = document.getElementById('tv-progress-wrap');
const bar = document.getElementById('tv-progress-bar');
@@ -1614,10 +1646,10 @@ function subirTvVideo(file) {
wrap.style.display = '';
const fd = new FormData();
fd.append('video', file);
fd.append('media', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', API + 'save_tv_video.php');
xhr.open('POST', API + 'save_tv_media.php');
xhr.upload.addEventListener('progress', e => {
if (!e.lengthComputable) return;
@@ -1631,11 +1663,8 @@ function subirTvVideo(file) {
try {
const json = JSON.parse(xhr.responseText);
if (!json.ok) { toast(json.error || 'Error al subir', 'error'); return; }
toast('Video guardado correctamente');
// Mostrar preview y ocultar zona de subida
document.getElementById('tv-preview').src = json.url;
document.getElementById('tv-preview-wrap').style.display = '';
document.getElementById('tv-upload-wrap').style.display = 'none';
toast(file.name + ' agregado a la playlist');
agregarItemTvMedia(json.item);
} catch(e) { toast('Respuesta inesperada del servidor', 'error'); }
});
@@ -1646,19 +1675,66 @@ function subirTvVideo(file) {
xhr.send(fd);
}
async function borrarTvVideo() {
if (!confirm('¿Quitar el video de la pantalla TV?')) return;
function agregarItemTvMedia(item) {
document.getElementById('tv-media-empty')?.remove();
const list = document.getElementById('tv-media-list');
const div = document.createElement('div');
div.className = 'tv-media-item d-flex align-items-center gap-3 p-2';
div.dataset.id = item.id;
div.setAttribute('draggable', 'true');
div.style.cssText = 'border:1px solid #e2e8f0;border-radius:10px';
const preview = item.tipo === 'video'
? `<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="${item.url}"></video>`
: `<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="${item.url}" alt="">`;
const durInput = item.tipo === 'imagen'
? `<div class="d-flex align-items-center gap-1">
<input type="number" min="1" class="form-control form-control-sm tv-media-dur" style="width:70px" value="${item.duracion_segundos}">
<span class="small text-muted">seg</span>
</div>` : '';
div.innerHTML = `
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
${preview}
<div class="flex-grow-1">
<div class="small fw-semibold"><i class="fas fa-${item.tipo==='video'?'film':'image'} me-1"></i>${item.tipo==='video'?'Video':'Imagen'}</div>
<div class="text-muted small text-truncate" style="max-width:300px">${item.url.split('/').pop()}</div>
</div>
${durInput}
<button class="btn btn-outline-danger btn-sm" onclick="borrarTvMedia(${item.id})"><i class="fas fa-trash"></i></button>`;
list.appendChild(div);
}
async function borrarTvMedia(id) {
if (!confirm('¿Quitar este elemento de la playlist?')) return;
try {
const res = await fetch(API + 'save_tv_video.php', {
const res = await fetch(API + 'delete_tv_media.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion: 'borrar' })
body: JSON.stringify({ id })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Video eliminado');
document.getElementById('tv-preview-wrap').style.display = 'none';
document.getElementById('tv-upload-wrap').style.display = '';
document.getElementById('tv-preview').src = '';
document.querySelector(`.tv-media-item[data-id="${id}"]`)?.remove();
const list = document.getElementById('tv-media-list');
if (!list.querySelector('.tv-media-item')) {
list.innerHTML = '<div class="text-muted small" id="tv-media-empty">Sin videos ni imágenes en la playlist.</div>';
}
toast('Elemento eliminado');
} catch(e) { toast('Error de conexión', 'error'); }
}
async function guardarOrdenTv() {
const items = Array.from(document.querySelectorAll('.tv-media-item')).map(el => ({
id: parseInt(el.dataset.id),
duracion_segundos: parseInt(el.querySelector('.tv-media-dur')?.value) || 8,
}));
if (!items.length) { toast('No hay elementos para guardar', 'error'); return; }
try {
const res = await fetch(API + 'reorder_tv_media.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Orden y duración guardados');
} catch(e) { toast('Error de conexión', 'error'); }
}