589 lines
19 KiB
HTML
589 lines
19 KiB
HTML
{% 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">◀◀</button>
|
||
<button class="btn-play" id="btn-play" onclick="togglePlay()">▶</button>
|
||
<button class="btn-control" onclick="nextTrack()" title="Siguiente">▶▶</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 ? '▮▮' : '▶';
|
||
|
||
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 %}
|