Add party player: YouTube queue with crossfading playback
Two frontends sharing one WebSocket-backed state on the server: - Player: fullscreen poster background with a small frame (30% of the screen) that drifts diagonally and reflects off the edges. Two stacked YouTube players hand over with a 5s equal-power crossfade so there is never silence between tracks. - Queue: add by URL, drag to reorder, delete, click to jump. Shows title and length per item, highlights what is playing, and tracks remaining vs. total playtime. No API key is required — titles come from YouTube's oEmbed endpoint and durations from a hidden muted probe player that cues each new video just to read its length. The fade is driven by both requestAnimationFrame and the interval tick: a hidden or throttled tab stops serving frames entirely, which would otherwise strand a handover half-faded with two tracks audible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Party Player — Queue</title>
|
||||
<link rel="stylesheet" href="/queue/queue.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Queue</h1>
|
||||
<div id="status" class="offline">offline</div>
|
||||
</header>
|
||||
|
||||
<form id="add-form" autocomplete="off">
|
||||
<input
|
||||
id="url"
|
||||
type="text"
|
||||
placeholder="Paste a YouTube URL…"
|
||||
aria-label="YouTube URL"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<p id="error" hidden></p>
|
||||
|
||||
<div id="totals">
|
||||
<div class="total">
|
||||
<span class="label">Remaining</span>
|
||||
<span class="value" id="remaining">0:00</span>
|
||||
</div>
|
||||
<div class="total">
|
||||
<span class="label">Total</span>
|
||||
<span class="value" id="total">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol id="list"></ol>
|
||||
<p id="empty">Nothing queued yet.</p>
|
||||
|
||||
<footer>
|
||||
<span class="label">Background</span>
|
||||
<div id="backgrounds"></div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/queue/queue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,265 @@
|
||||
:root {
|
||||
--accent: #ff4fc3;
|
||||
--ink: #14060f;
|
||||
--panel: #1f0c1a;
|
||||
--panel-2: #2a1124;
|
||||
--text: #f7e9f3;
|
||||
--muted: #a8859c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
max-width: 720px;
|
||||
background: var(--ink);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#status.offline {
|
||||
color: #ff8a5c;
|
||||
}
|
||||
|
||||
#status.online::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: #55e08a;
|
||||
}
|
||||
|
||||
/* --- add form ----------------------------------------------------------- */
|
||||
|
||||
#add-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #43213a;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#url:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
#add-form button {
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 12px 24px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#error {
|
||||
margin: 10px 2px 0;
|
||||
color: #ff8a5c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- totals ------------------------------------------------------------- */
|
||||
|
||||
#totals {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 22px 0 12px;
|
||||
}
|
||||
|
||||
.total {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid #37172f;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- list --------------------------------------------------------------- */
|
||||
|
||||
#list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item.current {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(255, 79, 195, 0.35);
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.item.drop-before {
|
||||
border-top-color: var(--accent);
|
||||
box-shadow: 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.item.drop-after {
|
||||
border-bottom-color: var(--accent);
|
||||
box-shadow: 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.handle {
|
||||
flex: none;
|
||||
cursor: grab;
|
||||
color: var(--muted);
|
||||
padding: 0 2px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.index {
|
||||
flex: none;
|
||||
width: 1.6em;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.item.current .index {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duration {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
background: #46203c;
|
||||
color: #ff8a5c;
|
||||
}
|
||||
|
||||
#empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
/* --- footer ------------------------------------------------------------- */
|
||||
|
||||
footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #331630;
|
||||
}
|
||||
|
||||
#backgrounds {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#backgrounds button {
|
||||
font: inherit;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #43213a;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#backgrounds button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { connect, formatDuration } from '/shared/socket.js';
|
||||
import { BACKGROUNDS } from '/config.js';
|
||||
|
||||
let state = { items: [], currentId: null, background: 'pink' };
|
||||
// Last playhead report, kept with its arrival time so the countdown can be
|
||||
// extrapolated smoothly between the 1 Hz updates.
|
||||
let progress = { id: null, currentTime: 0, at: 0 };
|
||||
|
||||
const list = document.getElementById('list');
|
||||
const empty = document.getElementById('empty');
|
||||
const errorBox = document.getElementById('error');
|
||||
const statusBox = document.getElementById('status');
|
||||
const backgroundBox = document.getElementById('backgrounds');
|
||||
|
||||
const socket = connect({ onMessage, onStatus });
|
||||
|
||||
// --- adding -------------------------------------------------------------
|
||||
|
||||
document.getElementById('add-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById('url');
|
||||
const url = input.value.trim();
|
||||
if (!url) return;
|
||||
showError(null);
|
||||
socket.send({ type: 'add', url });
|
||||
input.value = '';
|
||||
input.focus();
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message || '';
|
||||
errorBox.hidden = !message;
|
||||
}
|
||||
|
||||
// --- rendering ----------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
list.replaceChildren(...state.items.map(renderItem));
|
||||
empty.hidden = state.items.length > 0;
|
||||
renderTotals();
|
||||
}
|
||||
|
||||
function renderItem(item, index) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'item';
|
||||
li.dataset.id = item.id;
|
||||
li.draggable = true;
|
||||
if (item.id === state.currentId) li.classList.add('current');
|
||||
|
||||
const handle = document.createElement('span');
|
||||
handle.className = 'handle';
|
||||
handle.textContent = '⠿';
|
||||
handle.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const position = document.createElement('span');
|
||||
position.className = 'index';
|
||||
position.textContent = item.id === state.currentId ? '▶' : String(index + 1);
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'title';
|
||||
title.textContent = item.title;
|
||||
title.title = item.title;
|
||||
|
||||
const duration = document.createElement('span');
|
||||
duration.className = 'duration';
|
||||
duration.textContent = item.duration === null ? '–:––' : formatDuration(item.duration);
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.className = 'remove';
|
||||
remove.type = 'button';
|
||||
remove.textContent = '×';
|
||||
remove.title = 'Remove';
|
||||
remove.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
socket.send({ type: 'remove', id: item.id });
|
||||
});
|
||||
|
||||
li.append(handle, position, title, duration, remove);
|
||||
li.addEventListener('click', () => socket.send({ type: 'jump', id: item.id }));
|
||||
attachDrag(li);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderTotals() {
|
||||
let total = 0;
|
||||
let remaining = 0;
|
||||
let unknown = false;
|
||||
let passedCurrent = false;
|
||||
|
||||
const currentIndex = state.items.findIndex((item) => item.id === state.currentId);
|
||||
|
||||
state.items.forEach((item, index) => {
|
||||
if (item.duration === null) {
|
||||
unknown = true;
|
||||
return;
|
||||
}
|
||||
total += item.duration;
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
if (index === currentIndex) {
|
||||
remaining += Math.max(0, item.duration - playheadFor(item));
|
||||
passedCurrent = true;
|
||||
} else if (index > currentIndex) {
|
||||
remaining += item.duration;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentIndex === -1) remaining = total;
|
||||
else if (!passedCurrent) unknown = true;
|
||||
|
||||
const prefix = unknown ? '≥ ' : '';
|
||||
document.getElementById('total').textContent = prefix + formatDuration(total);
|
||||
document.getElementById('remaining').textContent = prefix + formatDuration(remaining);
|
||||
}
|
||||
|
||||
/** Playhead of the current item, extrapolated since the last report. */
|
||||
function playheadFor(item) {
|
||||
if (progress.id !== item.id) return 0;
|
||||
const elapsed = (Date.now() - progress.at) / 1000;
|
||||
return Math.min(progress.currentTime + elapsed, item.duration ?? Infinity);
|
||||
}
|
||||
|
||||
// Keeps the countdown ticking between server messages.
|
||||
setInterval(renderTotals, 1000);
|
||||
|
||||
// --- drag to reorder ----------------------------------------------------
|
||||
|
||||
let draggingId = null;
|
||||
|
||||
function attachDrag(li) {
|
||||
li.addEventListener('dragstart', (event) => {
|
||||
draggingId = li.dataset.id;
|
||||
li.classList.add('dragging');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox refuses to start a drag without payload.
|
||||
event.dataTransfer.setData('text/plain', li.dataset.id);
|
||||
});
|
||||
|
||||
li.addEventListener('dragend', () => {
|
||||
draggingId = null;
|
||||
li.classList.remove('dragging');
|
||||
clearDropMarkers();
|
||||
});
|
||||
|
||||
li.addEventListener('dragover', (event) => {
|
||||
if (!draggingId || li.dataset.id === draggingId) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
const box = li.getBoundingClientRect();
|
||||
const after = event.clientY > box.top + box.height / 2;
|
||||
clearDropMarkers();
|
||||
li.classList.add(after ? 'drop-after' : 'drop-before');
|
||||
});
|
||||
|
||||
li.addEventListener('dragleave', () => {
|
||||
li.classList.remove('drop-before', 'drop-after');
|
||||
});
|
||||
|
||||
li.addEventListener('drop', (event) => {
|
||||
if (!draggingId || li.dataset.id === draggingId) return;
|
||||
event.preventDefault();
|
||||
|
||||
const box = li.getBoundingClientRect();
|
||||
const after = event.clientY > box.top + box.height / 2;
|
||||
|
||||
const ids = state.items.map((item) => item.id).filter((id) => id !== draggingId);
|
||||
const target = ids.indexOf(li.dataset.id);
|
||||
ids.splice(after ? target + 1 : target, 0, draggingId);
|
||||
|
||||
clearDropMarkers();
|
||||
// Apply locally so the list does not flicker back before the echo lands.
|
||||
const byId = new Map(state.items.map((item) => [item.id, item]));
|
||||
state.items = ids.map((id) => byId.get(id));
|
||||
render();
|
||||
|
||||
socket.send({ type: 'reorder', ids });
|
||||
});
|
||||
}
|
||||
|
||||
function clearDropMarkers() {
|
||||
for (const el of list.querySelectorAll('.drop-before, .drop-after')) {
|
||||
el.classList.remove('drop-before', 'drop-after');
|
||||
}
|
||||
}
|
||||
|
||||
// --- background switcher ------------------------------------------------
|
||||
|
||||
const backgroundButtons = new Map();
|
||||
|
||||
for (const [key, { label }] of Object.entries(BACKGROUNDS)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = label;
|
||||
button.addEventListener('click', () => socket.send({ type: 'setBackground', key }));
|
||||
backgroundBox.appendChild(button);
|
||||
backgroundButtons.set(key, button);
|
||||
}
|
||||
|
||||
function renderBackground() {
|
||||
for (const [key, button] of backgroundButtons) {
|
||||
button.classList.toggle('active', key === state.background);
|
||||
}
|
||||
}
|
||||
|
||||
// --- socket -------------------------------------------------------------
|
||||
|
||||
function onMessage(msg) {
|
||||
if (msg.type === 'state') {
|
||||
state = msg.state;
|
||||
render();
|
||||
renderBackground();
|
||||
} else if (msg.type === 'progress') {
|
||||
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
||||
renderTotals();
|
||||
} else if (msg.type === 'error') {
|
||||
showError(msg.message);
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(status) {
|
||||
statusBox.textContent = status;
|
||||
statusBox.className = status;
|
||||
}
|
||||
Reference in New Issue
Block a user