commit inicial

This commit is contained in:
2026-04-23 00:39:58 -04:00
commit a1b9c0139d
32 changed files with 2836 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<div class="system-card">
<span class="system-label">Servidor</span>
<form method="post" action="/admin/system/restart"
onsubmit="return confirm('¿Reiniciar el servidor?')">
<button type="submit" class="btn-sm btn-system">🔄 Reiniciar</button>
</form>
<form method="post" action="/admin/system/shutdown"
onsubmit="return confirm('¿Apagar el servidor? Deberás iniciarlo manualmente.')">
<button type="submit" class="btn-sm btn-system btn-danger">⏹ Apagar</button>
</form>
</div>
<style>
.system-card {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem 1rem;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid #2a2a2a;
margin-top: .5rem;
}
.system-label {
font-size: .75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .5px;
margin-right: .25rem;
}
.btn-system { font-size: .82rem; }
</style>
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Cantina</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-container">
<h1>🔐 Admin</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/login" class="login-form">
<label>Usuario</label>
<input type="text" name="username" class="input" autofocus required>
<label>Contraseña</label>
<input type="password" name="password" class="input" required>
<button type="submit" class="btn-primary">Ingresar</button>
</form>
<a href="/" class="back-link">← Volver al reproductor</a>
</div>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block title %}Mantenedor — Cantina{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Mantenedor</h1>
<a href="/auth/logout" class="btn-sm btn-danger"
onclick="return confirm('¿Desconectar la cuenta de Spotify?')">⏏ Cuenta Spotify</a>
<a href="/admin/logout" class="btn-sm btn-danger">Cerrar sesión</a>
</div>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<!-- Formulario para agregar -->
<div class="card">
<h2>Agregar elemento</h2>
<form method="post" action="/admin/playlists" class="add-form">
<div class="form-row">
<input
type="text"
name="spotify_url"
class="input"
placeholder="URL de Spotify (canción, álbum, artista o playlist)"
required
>
<button type="submit" class="btn-primary">Agregar</button>
</div>
<small class="hint">
Acepta cualquier URL de <strong>open.spotify.com</strong>,
URI (<code>spotify:track:…</code>, <code>spotify:album:…</code>, etc.)
o ID de 22 caracteres (se asume playlist).
</small>
</form>
</div>
<!-- Lista -->
<div class="card">
<h2>Elementos configurados ({{ playlists | length }})</h2>
{% if playlists %}
<table class="table">
<thead>
<tr>
<th></th>
<th>Nombre</th>
<th>Tipo</th>
<th>Descripción</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for pl in playlists %}
{% set type_info = type_labels.get(pl.spotify_type, ('🎵', pl.spotify_type)) %}
<tr>
<td>
{% if pl.emoji %}
<div class="table-thumb-placeholder emoji-thumb">{{ pl.emoji }}</div>
{% elif pl.image_url %}
<img src="{{ pl.image_url }}" alt="" class="table-thumb">
{% else %}
<div class="table-thumb-placeholder">{{ type_info[0] }}</div>
{% endif %}
</td>
<td class="td-name">{{ pl.name }}</td>
<td>
<span class="type-badge type-{{ pl.spotify_type }}">
{{ type_info[0] }} {{ type_info[1] }}
</span>
</td>
<td class="td-desc">{{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %}</td>
<td class="td-actions">
<button class="btn-sm" onclick="toggleEdit({{ pl.id }})">✏️ Editar</button>
<form method="post" action="/admin/playlists/{{ pl.id }}/delete"
onsubmit="return confirm('¿Eliminar {{ pl.name }}?')">
<button type="submit" class="btn-sm btn-danger">Eliminar</button>
</form>
</td>
</tr>
<!-- Fila de edición inline -->
<tr id="edit-row-{{ pl.id }}" class="edit-row" style="display:none">
<td colspan="5">
<form method="post" action="/admin/playlists/{{ pl.id }}/edit" class="edit-form">
<div class="edit-fields">
<div class="edit-field">
<label>Nombre</label>
<input type="text" name="name" class="input"
value="{{ pl.name }}" required maxlength="200">
</div>
<div class="edit-field edit-field-desc">
<label>Descripción</label>
<input type="text" name="description" class="input"
value="{{ pl.description }}" maxlength="300">
</div>
<div class="edit-field edit-field-emoji">
<label>Emoticono <span class="hint-inline">(reemplaza imagen)</span></label>
<div class="emoji-input-wrap">
<input type="text" name="emoji" id="emoji-{{ pl.id }}" class="input emoji-input"
value="{{ pl.emoji }}" maxlength="8" placeholder="😀">
{% if pl.emoji %}
<button type="button" class="btn-sm emoji-clear"
onclick="clearEmoji({{ pl.id }})"></button>
{% endif %}
</div>
<div class="emoji-picker">
{% for e in ['🎵','🎶','🎸','🎹','🎺','🎻','🥁','🎤','🎧','🎼','🎷','🪗','🪘','🔥','⚡','🌟','✨','💫','🌈','🎉','🥳','🍕','🍺','🍹','🌙','☀️','🏖️','🌊','💃','🕺','🎭','🎬','🎮','🏆','❤️','🤘'] %}
<button type="button" class="emoji-opt"
onclick="pickEmoji({{ pl.id }}, '{{ e }}')">{{ e }}</button>
{% endfor %}
</div>
</div>
</div>
<div class="edit-actions">
<button type="submit" class="btn-primary btn-sm">Guardar</button>
<button type="button" class="btn-sm" onclick="toggleEdit({{ pl.id }})">Cancelar</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-msg">No hay elementos configurados todavía.</p>
{% endif %}
</div>
{% include "admin/_system_buttons.html" %}
</div>
<script>
function toggleEdit(id) {
const row = document.getElementById('edit-row-' + id);
const visible = row.style.display !== 'none';
row.style.display = visible ? 'none' : 'table-row';
if (!visible) row.querySelector('input[name="name"]').focus();
}
function pickEmoji(id, emoji) {
document.getElementById('emoji-' + id).value = emoji;
}
function clearEmoji(id) {
document.getElementById('emoji-' + id).value = '';
}
</script>
<style>
.td-actions { display: flex; gap: .4rem; align-items: center; }
.edit-row td { background: var(--surface2); padding: .75rem 1rem; }
.edit-form { display: flex; flex-direction: column; gap: .75rem; }
.edit-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: flex-start; }
.edit-field { display: flex; flex-direction: column; gap: .25rem; min-width: 140px; }
.edit-field-desc { flex: 2; }
.edit-field-emoji { min-width: 180px; }
.edit-field label { font-size: .75rem; color: var(--text-muted); }
.hint-inline { font-weight: 400; opacity: .6; }
.edit-actions { display: flex; gap: .4rem; align-items: center; }
.emoji-input-wrap { display: flex; gap: .35rem; align-items: center; }
.emoji-input { width: 70px; font-size: 1.2rem; text-align: center; padding: .3rem .4rem; }
.emoji-clear { padding: .3rem .5rem; line-height: 1; }
.emoji-picker { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .35rem; }
.emoji-opt {
background: var(--surface);
border: 1px solid #333;
border-radius: 6px;
font-size: 1.15rem;
width: 34px; height: 34px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .12s, transform .1s;
}
.emoji-opt:hover { background: #333; transform: scale(1.15); }
.emoji-thumb { font-size: 1.6rem; }
.type-badge {
display: inline-flex;
align-items: center;
gap: .3rem;
font-size: .75rem;
padding: .2rem .55rem;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
}
.type-playlist { background: rgba(29,185,84,.12); color: #1db954; border: 1px solid rgba(29,185,84,.3); }
.type-album { background: rgba(168,85,247,.12); color: #a855f7; border: 1px solid rgba(168,85,247,.3); }
.type-artist { background: rgba(59,130,246,.12); color: #3b82f6; border: 1px solid rgba(59,130,246,.3); }
.type-track { background: rgba(245,158,11,.12); color: #f59e0b; border: 1px solid rgba(245,158,11,.3); }
</style>
{% endblock %}
+207
View File
@@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}Estadísticas — Cantina{% endblock %}
{% block content %}
<div class="admin-container">
<!-- Encabezado -->
<div class="admin-header">
<h1>Estadísticas de reproducción</h1>
</div>
<!-- Selector de fecha -->
<div class="card date-nav">
<a href="/stats/?day={{ prev_date }}" class="btn-sm">← Día anterior</a>
<form method="get" action="/stats/" class="date-form">
<input type="date" name="day" value="{{ selected_date }}"
max="{{ selected_date if is_today else '' }}"
class="input date-input" onchange="this.form.submit()">
</form>
{% if not is_today %}
<a href="/stats/?day={{ next_date }}" class="btn-sm">Día siguiente →</a>
{% else %}
<span class="btn-sm" style="opacity:.4;cursor:default">Día siguiente →</span>
{% endif %}
<span class="total-badge">{{ total_plays }} reproduccion{{ 'es' if total_plays != 1 else '' }}</span>
</div>
{% if total_plays == 0 %}
<div class="card">
<p class="empty-msg" style="text-align:center;padding:2rem 0">
Sin reproducciones registradas para este día.<br>
<small>El historial se carga automáticamente desde Spotify cada 30 segundos.</small>
</p>
</div>
{% else %}
<div class="stats-grid">
<!-- Top Canciones -->
<div class="card">
<h2>🎵 Canciones más escuchadas</h2>
<div class="chart">
{% for item in top_tracks %}
<div class="chart-row">
<span class="rank">{{ loop.index }}</span>
<div class="chart-info">
<span class="chart-label">{{ item.label }}</span>
<div class="bar-wrap">
<div class="bar" style="width:{{ item.pct }}%"></div>
</div>
</div>
<span class="chart-count">{{ item.count }}x</span>
</div>
{% else %}
<p class="empty-msg">Sin datos</p>
{% endfor %}
</div>
</div>
<!-- Top Artistas -->
<div class="card">
<h2>🎤 Artistas más escuchados</h2>
<div class="chart">
{% for item in top_artists %}
<div class="chart-row">
<span class="rank">{{ loop.index }}</span>
<div class="chart-info">
<span class="chart-label">{{ item.label }}</span>
<div class="bar-wrap">
<div class="bar bar-artist" style="width:{{ item.pct }}%"></div>
</div>
</div>
<span class="chart-count">{{ item.count }}x</span>
</div>
{% else %}
<p class="empty-msg">Sin datos</p>
{% endfor %}
</div>
</div>
<!-- Top Géneros -->
<div class="card" style="grid-column: 1 / -1">
<h2>🎸 Géneros más escuchados</h2>
{% if top_genres %}
<div class="genre-bars">
{% for item in top_genres %}
<div class="genre-row">
<span class="genre-name">{{ item.label }}</span>
<div class="bar-wrap genre-bar-wrap">
<div class="bar bar-genre" style="width:{{ item.pct }}%">
<span class="bar-label-inside">{{ item.count }}x</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-msg">Sin información de géneros (los géneros se obtienen desde la API de artistas de Spotify).</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
.date-nav {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.date-form { display: flex; }
.date-input { width: auto; min-width: 160px; }
.total-badge {
margin-left: auto;
background: rgba(29,185,84,.12);
color: var(--green);
border: 1px solid rgba(29,185,84,.3);
border-radius: 20px;
padding: .3rem .9rem;
font-size: .82rem;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 700px) {
.stats-grid { grid-template-columns: 1fr; }
}
.chart { display: flex; flex-direction: column; gap: .55rem; margin-top: .25rem; }
.chart-row {
display: flex;
align-items: center;
gap: .6rem;
}
.rank {
width: 18px;
text-align: right;
font-size: .78rem;
color: var(--text-muted);
font-weight: 700;
flex-shrink: 0;
}
.chart-info { flex: 1; display: flex; flex-direction: column; gap: .2rem; overflow: hidden; }
.chart-label {
font-size: .85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-wrap {
height: 6px;
background: #2a2a2a;
border-radius: 3px;
overflow: hidden;
}
.bar {
height: 100%;
background: var(--green);
border-radius: 3px;
transition: width .5s ease;
}
.bar-artist { background: #a855f7; }
.chart-count {
font-size: .78rem;
color: var(--text-muted);
min-width: 28px;
text-align: right;
}
/* Géneros — barras horizontales grandes */
.genre-bars { display: flex; flex-direction: column; gap: .55rem; margin-top: .5rem; }
.genre-row { display: flex; align-items: center; gap: .75rem; }
.genre-name {
width: 160px;
font-size: .85rem;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-muted);
flex-shrink: 0;
}
.genre-bar-wrap { flex: 1; height: 22px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
.bar-genre {
height: 100%;
background: #f59e0b;
border-radius: 4px;
display: flex;
align-items: center;
transition: width .5s ease;
min-width: 36px;
}
.bar-label-inside { font-size: .72rem; font-weight: 600; color: #000; padding: 0 8px; white-space: nowrap; }
</style>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ action | capitalize }} — Cantina</title>
<link rel="stylesheet" href="/static/style.css">
{% if action == "reiniciando" %}
<meta http-equiv="refresh" content="4;url=/admin/playlists">
{% endif %}
</head>
<body>
<div class="system-action-container">
<div class="system-action-icon">
{% if action == "reiniciando" %}🔄{% else %}⏹{% endif %}
</div>
<h1>{{ message }}</h1>
{% if action == "reiniciando" %}
<p class="system-action-sub">Redirigiendo en unos segundos…</p>
<div class="spinner"></div>
{% else %}
<p class="system-action-sub">Puedes cerrar esta ventana.</p>
{% endif %}
</div>
<style>
.system-action-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
text-align: center;
padding: 2rem;
}
.system-action-icon { font-size: 3.5rem; }
h1 { font-size: 1.4rem; font-weight: 600; }
.system-action-sub { color: var(--text-muted); font-size: .9rem; }
.spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: var(--green);
border-radius: 50%;
animation: spin .8s linear infinite;
margin-top: .5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
{% extends "base.html" %}
{% block title %}Votación — Admin{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Control de Votación</h1>
<div style="display:flex;gap:.5rem">
<a href="/admin/playlists" class="btn-sm">Playlists</a>
<a href="/auth/logout" class="btn-sm btn-danger"
onclick="return confirm('¿Desconectar la cuenta de Spotify?')">⏏ Cuenta Spotify</a>
<a href="/admin/logout" class="btn-sm btn-danger">Cerrar sesión</a>
</div>
</div>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<!-- Configuración de rango horario -->
<div class="card">
<h2>Configurar votación</h2>
<form method="post" action="/admin/voting" class="voting-form">
<div class="time-row">
<div class="time-field">
<label>Hora de inicio</label>
<input type="time" name="start_time" class="input"
value="{{ config.start_time }}" required>
</div>
<div class="time-sep"></div>
<div class="time-field">
<label>Hora de fin</label>
<input type="time" name="end_time" class="input"
value="{{ config.end_time }}" required>
</div>
</div>
<label class="toggle-label">
<input type="checkbox" name="is_active"
{% if config.is_active %}checked{% endif %}>
<span class="toggle-text">Votación activa</span>
<span class="toggle-hint">
{% if config.is_active %}
Los usuarios pueden votar dentro del rango horario
{% else %}
La votación está desactivada
{% endif %}
</span>
</label>
<button type="submit" class="btn-primary">Guardar configuración</button>
</form>
<div class="status-badge {% if config.is_active %}status-active{% else %}status-inactive{% endif %}">
{% if config.is_active %}
🟢 Activa · Ventana: {{ config.start_time }} {{ config.end_time }}
{% else %}
🔴 Inactiva
{% endif %}
</div>
</div>
<!-- Resultados -->
<div class="card">
<div class="results-header">
<h2>Resultados ({{ total_votes }} votos)</h2>
<div style="display:flex;gap:.5rem;align-items:center">
{% if results and results[0].votes > 0 %}
<form method="post" action="/admin/voting/play-winner">
<button type="submit" class="btn-primary btn-sm">
▶ Reproducir ganador
</button>
</form>
{% endif %}
<form method="post" action="/admin/voting/reset"
onsubmit="return confirm('¿Reiniciar todos los votos?')">
<button type="submit" class="btn-sm btn-danger">Reiniciar votos</button>
</form>
</div>
</div>
{% if results %}
<div class="results-list">
{% for item in results %}
{% set pct = (item.votes / total_votes * 100) | round(1) if total_votes > 0 else 0 %}
<div class="result-row {% if loop.first and item.votes > 0 %}result-winner{% endif %}">
<div class="result-rank">{{ loop.index }}</div>
{% if item.playlist.image_url %}
<img src="{{ item.playlist.image_url }}" alt="" class="table-thumb">
{% else %}
<div class="table-thumb-placeholder">🎵</div>
{% endif %}
<div class="result-info">
<div class="result-name">
{{ item.playlist.name }}
{% if loop.first and item.votes > 0 %}
<span class="winner-badge">🏆 Ganador</span>
{% endif %}
</div>
<div class="result-bar-wrap">
<div class="result-bar" style="width: {{ pct }}%"></div>
</div>
</div>
<div class="result-count">
<strong>{{ item.votes }}</strong>
<span class="pct-label">{{ pct }}%</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-msg">No hay playlists configuradas.</p>
{% endif %}
</div>
</div>
<style>
.voting-form { display: flex; flex-direction: column; gap: 1rem; }
.time-row { display: flex; align-items: flex-end; gap: 1rem; }
.time-field { display: flex; flex-direction: column; gap: .3rem; flex: 1; }
.time-field label { font-size: .8rem; color: var(--text-muted); }
.time-sep { padding-bottom: .6rem; color: var(--text-muted); }
.toggle-label { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
.toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--green); }
.toggle-text { font-weight: 600; font-size: .9rem; }
.toggle-hint { font-size: .8rem; color: var(--text-muted); }
.status-badge {
display: inline-flex;
padding: .4rem .8rem;
border-radius: 20px;
font-size: .82rem;
font-weight: 500;
align-self: flex-start;
}
.status-active { background: rgba(29,185,84,.15); color: var(--green); border: 1px solid rgba(29,185,84,.3); }
.status-inactive { background: rgba(136,136,136,.1); color: var(--text-muted); border: 1px solid #333; }
.results-header { display: flex; justify-content: space-between; align-items: center; }
.results-list { display: flex; flex-direction: column; gap: .5rem; margin-top: .25rem; }
.result-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem;
border-radius: 8px;
background: var(--surface2);
}
.result-winner { border: 1px solid rgba(29,185,84,.4); background: rgba(29,185,84,.06); }
.result-rank { width: 20px; text-align: center; font-size: .82rem; color: var(--text-muted); font-weight: 600; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: .3rem; }
.result-name { font-size: .9rem; font-weight: 500; display: flex; align-items: center; gap: .5rem; }
.winner-badge { background: rgba(255,215,0,.15); color: gold; font-size: .75rem; padding: 1px 6px; border-radius: 10px; border: 1px solid rgba(255,215,0,.3); }
.result-bar-wrap { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.result-bar { height: 100%; background: var(--green); border-radius: 3px; transition: width .4s; }
.result-count { display: flex; flex-direction: column; align-items: flex-end; min-width: 50px; }
.result-count strong { font-size: 1.1rem; }
.pct-label { font-size: .75rem; color: var(--text-muted); }
</style>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Spotify Cantina{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
<div class="brand">🎵 Cantina</div>
<div class="nav-links">
<a href="/">Reproductor</a>
<a href="/admin/playlists">Playlists</a>
<a href="/admin/voting">Votación</a>
<a href="/stats/">Estadísticas</a>
</div>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+588
View File
@@ -0,0 +1,588 @@
{% extends "base.html" %}
{% block title %}Reproductor — Cantina{% endblock %}
{% block content %}
<div class="player-container">
<!-- Banner de votación (visible solo cuando está abierta) -->
<div id="voting-banner" class="voting-banner" style="display:none">
<span id="voting-banner-text">🗳️ Votación abierta</span>
</div>
<!-- Información de la canción actual -->
<div class="now-playing">
<img id="album-art" src="/static/placeholder.svg" alt="Álbum" class="album-art">
<div class="track-info">
<div id="track-name" class="track-name"></div>
<div id="artists" class="artists"></div>
<div id="album-name" class="album-label"></div>
<div id="device-name" class="device-label">Sin dispositivo activo</div>
</div>
</div>
<!-- Barra de progreso -->
<div class="progress-bar-container">
<span id="progress-time" class="time-label">0:00</span>
<div class="progress-bar-bg">
<div id="progress-bar" class="progress-bar-fill" style="width: 0%"></div>
</div>
<span id="duration-time" class="time-label">0:00</span>
</div>
<!-- Controles de reproducción -->
<div class="controls">
<button class="btn-control" onclick="prevTrack()" title="Anterior">&#9664;&#9664;</button>
<button class="btn-play" id="btn-play" onclick="togglePlay()">&#9654;</button>
<button class="btn-control" onclick="nextTrack()" title="Siguiente">&#9654;&#9654;</button>
</div>
<!-- Volumen -->
<div class="volume-row">
<span class="volume-icon">🔈</span>
<input type="range" id="volume-slider" min="0" max="100" value="50"
oninput="setVolume(this.value)" class="volume-slider">
<span class="volume-icon">🔊</span>
</div>
<!-- Selector de dispositivo -->
<div class="device-section">
<label class="section-label">Dispositivo</label>
<div class="device-row">
<select id="device-select" class="select" onchange="setDevice(this.value)">
<option value="">Cargando dispositivos...</option>
</select>
<button class="btn-sm" onclick="loadDevices()"></button>
</div>
</div>
<!-- Playlists / Votación -->
<div class="playlist-section">
<div class="playlist-section-header">
<label class="section-label" id="playlist-section-label">Playlists</label>
</div>
<div id="playlist-grid" class="playlist-grid">
<p class="empty-msg">Cargando...</p>
</div>
</div>
</div>
<!-- Modal de canciones -->
<div id="tracks-modal" class="modal-overlay" style="display:none" onclick="closeTracksModal(event)">
<div class="modal-box">
<div class="modal-header">
<div class="modal-title-wrap">
<div id="modal-thumb"></div>
<div>
<div id="modal-title" class="modal-title"></div>
<div id="modal-subtitle" class="modal-subtitle"></div>
</div>
</div>
<button class="modal-close" onclick="closeTracksModal()"></button>
</div>
<div id="modal-track-list" class="modal-track-list">
<p class="empty-msg">Cargando...</p>
</div>
</div>
</div>
<script>
let isPlaying = false;
let currentDeviceId = null;
let votingOpen = false;
let myVotedPlaylistId = null;
let cooldownRemaining = 0;
let cooldownTimer = null;
function msToTime(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// ── Reproductor ──────────────────────────────────────────────────────────────
async function fetchCurrent() {
try {
const res = await fetch('/player/current');
if (res.status === 401) { window.location.href = '/auth/login'; return; }
const data = await res.json();
isPlaying = data.playing;
document.getElementById('btn-play').innerHTML = isPlaying ? '&#9646;&#9646;' : '&#9654;';
if (data.track) {
document.getElementById('track-name').textContent = data.track;
document.getElementById('artists').textContent = data.artists;
document.getElementById('album-name').textContent = data.album;
document.getElementById('album-art').src = data.image || '/static/placeholder.svg';
document.getElementById('device-name').textContent = data.device ? `🔊 ${data.device}` : 'Sin dispositivo activo';
const pct = data.duration_ms > 0 ? (data.progress_ms / data.duration_ms) * 100 : 0;
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-time').textContent = msToTime(data.progress_ms);
document.getElementById('duration-time').textContent = msToTime(data.duration_ms);
if (data.volume !== null) document.getElementById('volume-slider').value = data.volume;
}
} catch (_) {}
}
async function togglePlay() {
const endpoint = isPlaying ? '/player/pause' : '/player/play';
const body = (!isPlaying && currentDeviceId) ? JSON.stringify({ device_id: currentDeviceId }) : '{}';
await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
setTimeout(fetchCurrent, 300);
}
async function nextTrack() {
await fetch('/player/next', { method: 'POST' });
setTimeout(fetchCurrent, 300);
}
async function prevTrack() {
await fetch('/player/previous', { method: 'POST' });
setTimeout(fetchCurrent, 300);
}
let volumeTimer = null;
function setVolume(val) {
clearTimeout(volumeTimer);
volumeTimer = setTimeout(async () => {
await fetch('/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: parseInt(val) }),
});
}, 200);
}
async function loadDevices() {
const res = await fetch('/player/devices');
const devices = await res.json();
const sel = document.getElementById('device-select');
sel.innerHTML = devices.length === 0
? '<option value="">Sin dispositivos activos</option>'
: devices.map(d => `<option value="${d.id}">${d.name} (${d.type})</option>`).join('');
if (devices.length > 0) {
currentDeviceId = devices[0].id;
sel.value = currentDeviceId;
}
}
function setDevice(deviceId) {
if (!deviceId) return;
currentDeviceId = deviceId;
fetch('/player/device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId }),
});
}
async function playItem(spotifyId, spotifyType) {
const uri = `spotify:${spotifyType}:${spotifyId}`;
const body = {};
if (spotifyType === 'track') {
body.uris = [uri];
} else {
body.context_uri = uri;
}
if (currentDeviceId) body.device_id = currentDeviceId;
await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setTimeout(fetchCurrent, 500);
}
// ── Votación ─────────────────────────────────────────────────────────────────
function startCooldownTick(seconds) {
clearInterval(cooldownTimer);
cooldownRemaining = seconds;
_updateCooldownUI();
if (seconds <= 0) return;
cooldownTimer = setInterval(() => {
cooldownRemaining--;
_updateCooldownUI();
if (cooldownRemaining <= 0) clearInterval(cooldownTimer);
}, 1000);
}
function _updateCooldownUI() {
const cards = document.querySelectorAll('.voting-card');
cards.forEach(card => {
const btn = card.querySelector('.vote-btn');
if (!btn) return;
if (cooldownRemaining > 0) {
btn.disabled = true;
btn.textContent = `${cooldownRemaining}s`;
} else {
btn.disabled = false;
btn.textContent = '👍 Votar';
}
});
}
async function castVote(playlistId) {
if (cooldownRemaining > 0) return;
const res = await fetch(`/voting/vote/${playlistId}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
myVotedPlaylistId = playlistId;
startCooldownTick(data.cooldown_seconds);
await fetchVotingStatus();
} else if (res.status === 429) {
const err = await res.json();
startCooldownTick(err.detail.remaining);
}
}
async function fetchVotingStatus() {
try {
const res = await fetch('/voting/status');
const data = await res.json();
votingOpen = data.is_open;
myVotedPlaylistId = data.my_last_vote_playlist_id;
// Solo sincronizar cooldown desde servidor si no hay timer local activo
if (cooldownRemaining <= 0 && data.cooldown_remaining > 0) {
startCooldownTick(data.cooldown_remaining);
}
const banner = document.getElementById('voting-banner');
const label = document.getElementById('playlist-section-label');
if (data.is_open) {
banner.style.display = 'flex';
document.getElementById('voting-banner-text').textContent =
`🗳️ Votación abierta · ${data.config.start_time} ${data.config.end_time}`;
label.textContent = 'Votar por una playlist';
} else {
banner.style.display = 'none';
label.textContent = 'Playlists';
}
renderPlaylists(data.playlists, data.is_open, data.my_last_vote_playlist_id);
} catch (_) {}
}
function renderPlaylists(playlists, isVoting, myVote) {
const grid = document.getElementById('playlist-grid');
if (!playlists || playlists.length === 0) {
grid.innerHTML = '<p class="empty-msg">No hay playlists configuradas. El administrador puede agregar en <a href="/admin/playlists">Admin</a>.</p>';
return;
}
const totalVotes = playlists.reduce((s, p) => s + p.votes, 0);
grid.innerHTML = playlists.map(pl => {
const isVoted = pl.id === myVote;
const pct = totalVotes > 0 ? Math.round(pl.votes / totalVotes * 100) : 0;
const img = pl.emoji
? `<div class="playlist-thumb-placeholder playlist-thumb-emoji">${pl.emoji}</div>`
: pl.image_url
? `<img src="${pl.image_url}" alt="${pl.name}" class="playlist-thumb">`
: `<div class="playlist-thumb-placeholder">🎵</div>`;
if (isVoting) {
const btnLabel = cooldownRemaining > 0 ? `${cooldownRemaining}s` : '👍 Votar';
const btnDisabled = cooldownRemaining > 0 ? 'disabled' : '';
return `
<div class="playlist-card clickable voting-card ${isVoted ? 'voted' : ''}">
${img}
<div class="playlist-card-name">${pl.name}</div>
<div class="vote-bar-wrap"><div class="vote-bar" style="width:${pct}%"></div></div>
<div class="vote-info">
<span class="vote-count">${pl.votes} votos</span>
${isVoted ? '<span class="voted-badge">✓ último voto</span>' : ''}
</div>
<button class="vote-btn" ${btnDisabled} onclick="castVote(${pl.id})">${btnLabel}</button>
</div>`;
} else {
return `
<div class="playlist-card clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-name">${pl.name}</div>
</div>`;
}
}).join('');
}
// ── Modal de canciones ────────────────────────────────────────────────────────
let _modalPlaylist = null;
async function openTracksModal(pl) {
_modalPlaylist = pl;
const modal = document.getElementById('tracks-modal');
const list = document.getElementById('modal-track-list');
const title = document.getElementById('modal-title');
const sub = document.getElementById('modal-subtitle');
const thumb = document.getElementById('modal-thumb');
title.textContent = pl.name;
const typeLabel = pl.spotify_type === 'artist' ? 'Canciones más populares' : '';
sub.textContent = pl.description || typeLabel;
thumb.innerHTML = pl.emoji
? `<div class="playlist-thumb-placeholder playlist-thumb-emoji modal-thumb-emoji">${pl.emoji}</div>`
: pl.image_url
? `<img src="${pl.image_url}" class="modal-thumb-img" alt="">`
: `<div class="playlist-thumb-placeholder modal-thumb-emoji">🎵</div>`;
list.innerHTML = '<p class="empty-msg">Cargando...</p>';
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
try {
const res = await fetch(`/player/tracks?id=${pl.spotify_id}&type=${pl.spotify_type}`);
if (!res.ok) throw new Error();
const tracks = await res.json();
if (!tracks.length) {
list.innerHTML = '<p class="empty-msg">Sin canciones disponibles.</p>';
return;
}
list.innerHTML = tracks.map((t, i) => `
<div class="modal-track-row" onclick="playTrackFromModal('${t.id}')">
<span class="modal-track-num">${i + 1}</span>
<div class="modal-track-info">
<div class="modal-track-name">${t.name}</div>
<div class="modal-track-artists">${t.artists}</div>
</div>
<span class="modal-track-dur">${msToTime(t.duration_ms)}</span>
</div>`).join('');
} catch (_) {
const msgs = [
'¿Y si simplemente la ponemos y ya? 🎶',
'Las canciones son un misterio... ponla y descúbrelas. 🎵',
'No hay preview, pero la vibra se siente. Dale play. ✨',
'Confía en el proceso. Y en la playlist. 🙌',
'A veces hay que tirar pa\'lante sin leer el menú. 🍽️',
'No sabemos qué hay adentro, pero suena bien. 🔊',
'¿Qué es la vida sin un poco de sorpresa musical? 🎲',
];
const msg = msgs[Math.floor(Math.random() * msgs.length)];
list.innerHTML = `<p class="empty-msg">${msg}</p>`;
}
}
function closeTracksModal(event) {
if (event && event.target !== document.getElementById('tracks-modal')) return;
document.getElementById('tracks-modal').style.display = 'none';
document.body.style.overflow = '';
}
async function playTrackFromModal(trackId) {
const body = { uris: [`spotify:track:${trackId}`] };
if (currentDeviceId) body.device_id = currentDeviceId;
await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setTimeout(fetchCurrent, 400);
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeTracksModal();
});
loadDevices();
fetchCurrent();
fetchVotingStatus();
setInterval(fetchCurrent, 3000);
setInterval(fetchVotingStatus, 5000);
</script>
<style>
.voting-banner {
display: flex;
align-items: center;
justify-content: center;
background: rgba(29,185,84,.12);
border: 1px solid rgba(29,185,84,.35);
border-radius: 8px;
padding: .6rem 1rem;
font-size: .88rem;
font-weight: 500;
color: var(--green);
gap: .5rem;
}
.voting-card { cursor: pointer; position: relative; }
.voting-card.voted { border: 2px solid var(--green); }
.vote-bar-wrap {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
margin-top: 2px;
}
.vote-bar { height: 100%; background: var(--green); border-radius: 2px; transition: width .4s; }
.vote-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-size: .72rem;
color: var(--text-muted);
}
.voted-badge {
color: var(--green);
font-weight: 600;
}
.playlist-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.vote-btn {
width: 100%;
margin-top: 4px;
padding: .35rem 0;
background: var(--green);
color: #000;
font-weight: 700;
font-size: .78rem;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background .15s, opacity .15s;
}
.vote-btn:hover:not(:disabled) { background: var(--green-dark); }
.vote-btn:disabled {
background: #333;
color: var(--text-muted);
cursor: not-allowed;
opacity: .8;
}
/* ── Modal canciones ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal-box {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
border-bottom: 1px solid #2a2a2a;
gap: .8rem;
}
.modal-title-wrap {
display: flex;
align-items: center;
gap: .8rem;
min-width: 0;
}
.modal-thumb-img {
width: 52px;
height: 52px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.modal-thumb-emoji {
width: 52px !important;
height: 52px !important;
font-size: 1.6rem !important;
flex-shrink: 0;
}
.modal-title {
font-size: 1rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-subtitle {
font-size: .78rem;
color: var(--text-muted);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: .2rem .4rem;
border-radius: 4px;
flex-shrink: 0;
}
.modal-close:hover { color: #fff; background: #2a2a2a; }
.modal-track-list {
overflow-y: auto;
padding: .4rem 0;
}
.modal-track-row {
display: flex;
align-items: center;
gap: .8rem;
padding: .55rem 1.2rem;
cursor: pointer;
transition: background .12s;
}
.modal-track-row:hover { background: #2a2a2a; }
.modal-track-num {
font-size: .8rem;
color: var(--text-muted);
width: 1.4rem;
text-align: right;
flex-shrink: 0;
}
.modal-track-info {
flex: 1;
min-width: 0;
}
.modal-track-name {
font-size: .88rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-track-artists {
font-size: .75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-track-dur {
font-size: .78rem;
color: var(--text-muted);
flex-shrink: 0;
}
</style>
{% endblock %}