Add self-hostable jukebox voting web page
Build / build (push) Successful in 1m17s

This commit is contained in:
Michael Burgess
2026-08-07 11:51:21 -04:00
parent bdb18f9952
commit 8049aa04af
2 changed files with 748 additions and 0 deletions
+9
View File
@@ -224,6 +224,15 @@ jukebox:
# ... id|label pairs; "id" must be a valid Bukkit Sound enum name
```
**The voting page:** [`web/jukebox.html`](web/jukebox.html) in this repo is a ready-to-use,
dependency-free static page implementing all of this — self-host it anywhere (same reverse
proxy as the API, GitHub Pages, S3, or just open the file locally for testing). It asks the
player for your server's HTTPS address once (saved in their browser), then for their vote
code, then shows arenas as tabs and tracks as clickable "discs" with a live vote tally that
polls every few seconds. A live preview of the same page: https://claude.ai/code/artifact/bfed1aa7-8040-47bc-a55b-970c07f83c4f
(hosted for previewing the UI only — point it at your own server's address to actually vote;
it won't work against a plain-HTTP address for the mixed-content reason above).
**How it works:**
1. A player runs `/blockparty musicvote` in-game and gets a short numeric code (valid for
+739
View File
@@ -0,0 +1,739 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BlockParty Jukebox</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<style>
:root {
--bg: #17111f;
--surface: #221934;
--surface-2: #2c2144;
--surface-3: #382a58;
--line: #3d2f5c;
--text: #f4eefc;
--text-dim: #b8a9d4;
--text-faint: #7c6a9c;
--accent: #ffb833;
--accent-strong: #ffd166;
--accent-ink: #241804;
--win: #4ee6ab;
--danger: #ff6b7f;
--disc-a: #ff4fa3;
--disc-b: #4fd1ff;
--disc-c: #b5ff4f;
--disc-d: #ff9c4f;
--shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.6);
--radius: 14px;
--font-display: "Arial Black", "Arial Narrow Bold", Impact, "Segoe UI", sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
/* This page commits deliberately to one dark, arcade-jukebox visual world (like a real
jukebox's own lit marquee) rather than adapting to the viewer's light/dark preference. */
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
background:
radial-gradient(60rem 30rem at 15% -10%, rgba(255, 184, 51, 0.10), transparent 60%),
radial-gradient(50rem 26rem at 110% 10%, rgba(79, 209, 255, 0.10), transparent 55%),
var(--bg);
}
.wrap {
max-width: 860px;
margin: 0 auto;
padding: clamp(20px, 4vw, 48px) clamp(16px, 4vw, 32px) 64px;
}
/* ---------- header ---------- */
header.marquee {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
padding: 8px 0 28px;
}
.plate {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 5px 14px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--text-dim);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 14px;
}
.plate .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-faint);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.plate .dot.live {
background: var(--win);
box-shadow: 0 0 0 3px rgba(78, 230, 171, 0.18);
}
.plate .dot.err {
background: var(--danger);
box-shadow: 0 0 0 3px rgba(255, 107, 127, 0.18);
}
h1 {
font-family: var(--font-display);
font-weight: 900;
font-size: clamp(34px, 7vw, 54px);
line-height: 0.95;
letter-spacing: -0.01em;
margin: 0;
text-wrap: balance;
background: linear-gradient(180deg, var(--accent-strong), var(--accent) 70%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
h1 small {
display: block;
font-family: var(--font-body);
font-weight: 700;
font-size: clamp(12px, 2.4vw, 14px);
letter-spacing: 0.28em;
color: var(--text-dim);
background: none;
-webkit-text-fill-color: var(--text-dim);
margin-top: 10px;
}
/* ---------- panels ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 18px;
}
.panel h2 {
margin: 0 0 4px;
font-size: 13px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
font-weight: 700;
}
.panel p.hint {
margin: 0 0 16px;
color: var(--text-faint);
font-size: 13.5px;
line-height: 1.5;
max-width: 60ch;
}
label {
display: block;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: grid;
gap: 12px;
}
@media (min-width: 620px) {
.row.cols-2 { grid-template-columns: 1fr 1fr; }
.row.cols-connect { grid-template-columns: 1fr auto; align-items: end; }
}
input[type="text"], input[type="url"] {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--text);
font-family: var(--font-mono);
font-size: 15px;
padding: 11px 13px;
border-radius: 10px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder { color: var(--text-faint); }
input:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 184, 51, 0.18);
}
button {
font-family: var(--font-body);
font-weight: 700;
font-size: 14px;
border-radius: 10px;
border: 1px solid transparent;
padding: 11px 18px;
cursor: pointer;
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
}
button:active { transform: translateY(1px); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: 0.5; }
button.primary {
background: var(--accent);
color: var(--accent-ink);
}
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.ghost {
background: transparent;
border-color: var(--line);
color: var(--text-dim);
}
button.ghost:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.status-line {
margin-top: 10px;
font-size: 13px;
color: var(--text-faint);
min-height: 1.4em;
}
.status-line.err { color: var(--danger); }
.status-line.ok { color: var(--win); }
/* ---------- arena tabs ---------- */
.arena-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.arena-tab {
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--text-dim);
padding: 8px 14px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.arena-tab .state {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-3);
}
.arena-tab .state.running { color: var(--win); }
.arena-tab.active {
background: var(--surface-3);
border-color: var(--accent);
color: var(--text);
}
.arena-tab:hover { border-color: var(--accent); }
/* ---------- disc grid ---------- */
.disc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.disc {
position: relative;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px 14px;
text-align: left;
color: var(--text);
display: flex;
flex-direction: column;
gap: 10px;
min-height: 118px;
}
.disc-label {
font-weight: 800;
font-size: 15px;
line-height: 1.2;
text-wrap: balance;
}
.disc .ring {
width: 30px;
height: 30px;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, var(--surface) 0 4px, transparent 4.5px),
conic-gradient(from 90deg, var(--disc-color, var(--disc-a)), var(--surface-3) 0 0);
box-shadow: inset 0 0 0 1px var(--line);
}
.disc .tally {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
color: var(--text-faint);
}
.disc .bar {
position: absolute;
left: 0;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 0 0 0 12px;
transition: width 0.4s ease;
}
.disc.leading {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), var(--shadow);
}
.disc.leading .disc-label::after {
content: "\2605 leading";
display: inline-block;
margin-left: 8px;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
vertical-align: middle;
font-weight: 700;
}
.disc.mine {
outline: 2px dashed var(--win);
outline-offset: -2px;
}
.disc-vote-btn {
position: absolute;
inset: 0;
background: transparent;
border: none;
border-radius: 12px;
cursor: pointer;
}
.disc-vote-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.disc-vote-btn:hover ~ * ,
.disc:has(.disc-vote-btn:hover) {
border-color: var(--accent-strong);
}
.now-playing {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 12px;
margin-bottom: 16px;
}
.now-playing .needle {
width: 34px;
height: 34px;
border-radius: 50%;
background: conic-gradient(var(--disc-b), var(--disc-a), var(--disc-c), var(--disc-b));
flex-shrink: 0;
}
.now-playing .np-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.now-playing .np-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
}
.now-playing .np-track {
font-weight: 800;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
color: var(--text-faint);
font-size: 14px;
padding: 24px 0;
text-align: center;
}
footer {
text-align: center;
color: var(--text-faint);
font-size: 12px;
margin-top: 28px;
line-height: 1.6;
}
footer code {
font-family: var(--font-mono);
background: var(--surface-2);
padding: 1px 6px;
border-radius: 6px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
</head>
<body>
<div class="wrap">
<header class="marquee">
<div class="plate"><span class="dot" id="connDot"></span><span id="connLabel">not connected</span></div>
<h1>BlockParty<small>Jukebox &middot; live track voting</small></h1>
</header>
<section class="panel" id="connectPanel">
<h2>Connect to your server</h2>
<p class="hint">Enter the address your server admin gave you for the jukebox API (it must start with <code>https://</code>). This is stored only in your browser.</p>
<div class="row cols-connect">
<div class="field">
<label for="serverUrl">Server address</label>
<input type="url" id="serverUrl" placeholder="https://blockparty.yourdomain.com" autocomplete="off" spellcheck="false" />
</div>
<button class="primary" id="connectBtn" type="button">Connect</button>
</div>
<div class="status-line" id="connectStatus"></div>
</section>
<section class="panel" id="codePanel" hidden>
<h2>Your vote code</h2>
<p class="hint">Run <code>/blockparty musicvote</code> in-game to get a one-time code, then enter it here. It proves you're a real player without needing a separate login.</p>
<div class="row cols-2">
<div class="field">
<label for="voteCode">Code from /blockparty musicvote</label>
<input type="text" id="voteCode" placeholder="e.g. 482913" inputmode="numeric" autocomplete="off" />
</div>
</div>
</section>
<section class="panel" id="arenaPanel" hidden>
<h2>Arena</h2>
<p class="hint" id="arenaHint">Pick which arena you're playing in.</p>
<div class="arena-tabs" id="arenaTabs"></div>
</section>
<section class="panel" id="tracksPanel" hidden>
<h2>Vote for the next track</h2>
<p class="hint">Tracks resume between rounds, so the leading pick can change mid-match. Tap a disc to vote.</p>
<div id="nowPlayingSlot"></div>
<div class="disc-grid" id="discGrid"></div>
</section>
<div class="status-line" id="voteStatus" style="text-align:center;"></div>
<footer>
Powered by the BlockParty plugin's optional jukebox API &middot; votes reset when the arena returns to waiting<br />
No account needed &mdash; codes come only from <code>/blockparty musicvote</code> in-game.
</footer>
</div>
<script>
(function () {
"use strict";
var DISC_COLORS = ["#ff4fa3", "#4fd1ff", "#b5ff4f", "#ff9c4f", "#c58bff", "#ffe45c"];
var state = {
baseUrl: localStorage.getItem("bp_jukebox_url") || "",
code: localStorage.getItem("bp_jukebox_code") || "",
tracks: [],
arenas: [],
selectedArena: localStorage.getItem("bp_jukebox_arena") || null,
pollTimer: null,
connected: false
};
var $ = function (id) { return document.getElementById(id); };
var connDot = $("connDot"), connLabel = $("connLabel");
var serverUrlInput = $("serverUrl"), connectBtn = $("connectBtn"), connectStatus = $("connectStatus");
var codePanel = $("codePanel"), voteCodeInput = $("voteCode");
var arenaPanel = $("arenaPanel"), arenaTabs = $("arenaTabs"), arenaHint = $("arenaHint");
var tracksPanel = $("tracksPanel"), discGrid = $("discGrid"), nowPlayingSlot = $("nowPlayingSlot");
var voteStatus = $("voteStatus");
serverUrlInput.value = state.baseUrl;
voteCodeInput.value = state.code;
voteCodeInput.addEventListener("input", function () {
state.code = voteCodeInput.value.trim();
localStorage.setItem("bp_jukebox_code", state.code);
});
function setConn(mode, label) {
connDot.className = "dot" + (mode === "live" ? " live" : mode === "err" ? " err" : "");
connLabel.textContent = label;
}
function normalizeBase(url) {
return url.replace(/\/+$/, "");
}
function api(path) {
return normalizeBase(state.baseUrl) + path;
}
function setConnectStatus(msg, kind) {
connectStatus.textContent = msg || "";
connectStatus.className = "status-line" + (kind ? " " + kind : "");
}
function setVoteStatus(msg, kind) {
voteStatus.textContent = msg || "";
voteStatus.className = "status-line" + (kind ? " " + kind : "");
}
async function connect() {
var url = serverUrlInput.value.trim();
if (!url) {
setConnectStatus("Enter a server address first.", "err");
return;
}
if (!/^https:\/\//i.test(url)) {
setConnectStatus("Address must start with https:// — a page served over HTTPS can't call plain http://.", "err");
return;
}
state.baseUrl = url;
localStorage.setItem("bp_jukebox_url", url);
setConn("", "connecting…");
setConnectStatus("Connecting…");
try {
var res = await fetch(api("/api/health"), { cache: "no-store" });
if (!res.ok) throw new Error("Server responded with " + res.status);
await res.json();
state.connected = true;
setConn("live", "connected");
setConnectStatus("Connected.", "ok");
codePanel.hidden = false;
await loadTracks();
await refreshArenas();
startPolling();
} catch (err) {
state.connected = false;
setConn("err", "connection failed");
setConnectStatus(describeError(err), "err");
}
}
function describeError(err) {
if (err instanceof TypeError) {
return "Couldn't reach that address. Check it's correct, reachable, and served over HTTPS with a valid certificate.";
}
return err.message || "Something went wrong.";
}
async function loadTracks() {
var res = await fetch(api("/api/tracks"), { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load track list (" + res.status + ")");
state.tracks = await res.json();
}
async function refreshArenas() {
try {
var res = await fetch(api("/api/arenas"), { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load arenas (" + res.status + ")");
state.arenas = await res.json();
renderArenaTabs();
renderTracks();
setConn("live", "connected");
} catch (err) {
setConn("err", "lost connection");
}
}
function renderArenaTabs() {
if (!state.arenas.length) {
arenaPanel.hidden = false;
arenaTabs.innerHTML = '<div class="empty">No arenas found on this server yet.</div>';
tracksPanel.hidden = true;
return;
}
arenaPanel.hidden = false;
if (!state.selectedArena || !state.arenas.some(function (a) { return a.name === state.selectedArena; })) {
state.selectedArena = state.arenas[0].name;
}
arenaTabs.innerHTML = "";
state.arenas.forEach(function (arena) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "arena-tab" + (arena.name === state.selectedArena ? " active" : "");
var stateClass = arena.state === "RUNNING" ? " running" : "";
btn.innerHTML = escapeHtml(arena.name) + ' <span class="state' + stateClass + '">' + escapeHtml(arena.state) + "</span>";
btn.addEventListener("click", function () {
state.selectedArena = arena.name;
localStorage.setItem("bp_jukebox_arena", arena.name);
renderArenaTabs();
renderTracks();
});
arenaTabs.appendChild(btn);
});
arenaHint.textContent = state.arenas.length + " arena" + (state.arenas.length === 1 ? "" : "s") + " on this server — pick the one you're playing in.";
}
function currentArena() {
return state.arenas.find(function (a) { return a.name === state.selectedArena; });
}
function renderTracks() {
var arena = currentArena();
if (!arena || !state.tracks.length) {
tracksPanel.hidden = true;
return;
}
tracksPanel.hidden = false;
if (arena.currentTrack) {
var label = trackLabel(arena.currentTrack);
nowPlayingSlot.innerHTML =
'<div class="now-playing"><div class="needle"></div><div class="np-text">' +
'<span class="np-label">Now playing in ' + escapeHtml(arena.name) + '</span>' +
'<span class="np-track">' + escapeHtml(label) + "</span></div></div>";
} else {
nowPlayingSlot.innerHTML = "";
}
var votes = arena.votes || {};
var totalVotes = Object.values(votes).reduce(function (a, b) { return a + b; }, 0);
var maxVotes = Math.max.apply(null, [0].concat(Object.values(votes)));
discGrid.innerHTML = "";
state.tracks.forEach(function (track, i) {
var count = votes[track.id] || 0;
var isLeading = count > 0 && count === maxVotes;
var pct = totalVotes ? Math.round((count / totalVotes) * 100) : 0;
var card = document.createElement("div");
card.className = "disc" + (isLeading ? " leading" : "");
card.style.setProperty("--disc-color", DISC_COLORS[i % DISC_COLORS.length]);
card.innerHTML =
'<div class="ring"></div>' +
'<div class="disc-label">' + escapeHtml(track.label) + "</div>" +
'<div class="tally"><span>' + count + " vote" + (count === 1 ? "" : "s") + '</span><span>' + pct + "%</span></div>" +
'<div class="bar" style="width:' + pct + '%"></div>' +
'<button class="disc-vote-btn" type="button" aria-label="Vote for ' + escapeHtml(track.label) + '"></button>';
card.querySelector(".disc-vote-btn").addEventListener("click", function () {
castVote(track.id);
});
discGrid.appendChild(card);
});
}
function trackLabel(id) {
var found = state.tracks.find(function (t) { return t.id === id; });
return found ? found.label : id;
}
async function castVote(trackId) {
if (!state.code) {
setVoteStatus("Enter your vote code first (run /blockparty musicvote in-game).", "err");
voteCodeInput.focus();
return;
}
var arena = state.selectedArena;
setVoteStatus("Casting vote…");
try {
var url = api("/api/vote") + "?arena=" + encodeURIComponent(arena) +
"&code=" + encodeURIComponent(state.code) +
"&track=" + encodeURIComponent(trackId);
var res = await fetch(url, { method: "POST" });
var body = await res.json().catch(function () { return {}; });
if (!res.ok) {
setVoteStatus(body.error || "Vote failed (" + res.status + ")", "err");
return;
}
setVoteStatus("Vote counted for " + trackLabel(trackId) + ".", "ok");
await refreshArenas();
} catch (err) {
setVoteStatus(describeError(err), "err");
}
}
function startPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = setInterval(refreshArenas, 4000);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
connectBtn.addEventListener("click", connect);
serverUrlInput.addEventListener("keydown", function (e) { if (e.key === "Enter") connect(); });
if (state.baseUrl) {
connect();
}
})();
</script>
</body>
</html>