Files
spotify-cantina/app/templates/index.html
T
2026-04-23 18:20:08 -04:00

623 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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();
const wasOpen = votingOpen;
votingOpen = data.is_open;
myVotedPlaylistId = data.my_last_vote_playlist_id;
if (wasOpen && !votingOpen && data.playlists?.length) {
const totalVotes = data.playlists.reduce((s, p) => s + p.votes, 0);
if (totalVotes > 0) {
const winner = data.playlists.reduce((best, p) => p.votes > best.votes ? p : best);
playItem(winner.spotify_id, winner.spotify_type);
}
}
// 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 voting-card ${isVoted ? 'voted' : ''}">
<div class="playlist-card-clickable clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-info">
<div class="playlist-card-name">${pl.name}</div>
${pl.description ? `<div class="playlist-card-desc">${pl.description}</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>
</div>
</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-info">
<div class="playlist-card-name">${pl.name}</div>
${pl.description ? `<div class="playlist-card-desc">${pl.description}</div>` : ''}
</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 { position: relative; }
.playlist-card-clickable {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
cursor: pointer;
}
.playlist-card-clickable:hover .playlist-card-name { color: var(--green); }
.playlist-card-desc {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voting-card.voted { border: 2px solid var(--green); }
.vote-bar-wrap {
width: 100%;
height: 4px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.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 {
padding: .4rem .75rem;
background: var(--green);
color: #000;
font-weight: 700;
font-size: .78rem;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background .15s, opacity .15s;
white-space: nowrap;
flex-shrink: 0;
}
.vote-btn:hover:not(:disabled) { background: var(--green-dark); }
.vote-btn:disabled {
background: var(--surface3);
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: var(--modal-bg);
border: 1px solid var(--border2);
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 var(--surface2);
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: var(--text); background: var(--surface2); }
.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: var(--surface2); }
.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 %}