🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,593 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Sendspin Live</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--panel: #161a22;
--panel2: #1d222c;
--border: #2a303c;
--text: #e6e9ef;
--muted: #8b94a7;
--accent: #4c9aff;
--accent-hover: #3a86e8;
--good: #4ade80;
--bad: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
background: var(--panel);
}
header h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
margin-right: auto;
letter-spacing: 0.3px;
}
.pill {
padding: 2px 10px;
border-radius: 999px;
background: var(--panel2);
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pill.playing { background: rgba(74,222,128,0.15); color: var(--good); }
.pill.paused { background: rgba(245,158,11,0.15); color: var(--warn); }
.pill.off { background: rgba(139,148,167,0.15); color: var(--muted); }
main {
max-width: 1100px;
margin: 24px auto;
padding: 0 24px 80px;
display: grid;
gap: 24px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 800px) { main { grid-template-columns: 1fr; } }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
}
.card h2 {
margin: 0 0 14px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.row:first-of-type { border-top: none; }
.row .grow { flex: 1; }
.muted { color: var(--muted); font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
button, select, input[type=text], input[type=number] {
background: var(--panel2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
font: inherit;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.danger { color: var(--bad); }
button.danger:hover:not(:disabled) { border-color: var(--bad); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-group { display: inline-flex; gap: 6px; }
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.connected { background: var(--good); }
.dot.discovered { background: var(--warn); }
input[type=range] { flex: 1; accent-color: var(--accent); }
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--muted); }
label.inline { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; }
.stack > * + * { margin-top: 14px; }
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
background: var(--panel2);
color: var(--muted);
font-size: 11px;
}
.badge.active { background: rgba(76,154,255,0.15); color: var(--accent); }
.members-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
background: var(--panel2);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 22px;
width: min(500px, 92vw);
max-height: 90vh;
overflow-y: auto;
}
.modal h3 { margin: 0 0 14px; font-size: 16px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.empty {
text-align: center;
color: var(--muted);
padding: 18px 0;
font-size: 13px;
}
input[type=text]:focus, input[type=number]:focus, select:focus {
outline: none;
border-color: var(--accent);
}
</style>
</head>
<body>
<header>
<h1>Sendspin Live</h1>
<span id="state-pill" class="pill off">off</span>
<span id="active-pill" class="pill"></span>
</header>
<main>
<section class="card">
<h2>
Now playing
</h2>
<div class="stack">
<div class="row">
<span class="grow">Active preset</span>
<select id="active-preset"></select>
</div>
<div class="row">
<span class="grow">Playback</span>
<div class="btn-group">
<button id="btn-play">Play</button>
<button id="btn-pause">Pause</button>
<button id="btn-stop">Stop</button>
</div>
</div>
<div class="row">
<span class="grow">Volume <span id="volume-label" class="mono"></span></span>
</div>
<div class="row">
<input id="volume" type="range" min="0" max="100" step="1" />
</div>
<div class="row">
<span class="grow">Members</span>
</div>
<div id="members" class="muted"></div>
</div>
</section>
<section class="card">
<h2>
Discovered clients
<button id="btn-refresh-clients" class="muted" style="background:none;border:none;font-size:12px;">refresh</button>
</h2>
<div id="clients"></div>
</section>
<section class="card" style="grid-column: 1 / -1;">
<h2>
Presets
<button id="btn-new-preset" class="primary">New preset</button>
</h2>
<div id="presets"></div>
</section>
</main>
<div class="modal-overlay" id="modal">
<div class="modal">
<h3 id="modal-title">New preset</h3>
<form id="preset-form" autocomplete="off">
<label for="f-id">ID <span class="muted">(lowercase, kebab-case)</span></label>
<input id="f-id" type="text" required pattern="[a-z0-9]+(-[a-z0-9]+)*" style="width:100%" />
<label for="f-name">Name</label>
<input id="f-name" type="text" required style="width:100%" />
<label for="f-source">PipeWire source</label>
<select id="f-source" required style="width:100%"></select>
<label>Members</label>
<div class="members-list" id="f-members"></div>
<label for="f-custom-member">Add member by client_id</label>
<div style="display:flex;gap:6px;">
<input id="f-custom-member" type="text" placeholder="speaker-living-room" style="flex:1" />
<button type="button" id="f-add-member">Add</button>
</div>
<div class="modal-actions">
<button type="button" id="f-cancel">Cancel</button>
<button type="submit" class="primary" id="f-save">Save</button>
</div>
</form>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let state = { active_preset: "", playback: "off", volume: 0, members: [] };
let presets = [];
let clients = [];
let sources = [];
let editing = null; // preset being edited, or null for new
let formMembers = []; // strings (client_ids)
let volumeDirty = false; // user holding the slider — don't clobber
async function api(method, path, body) {
const opts = { method, headers: { "content-type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).error || msg; } catch {}
throw new Error(`${method} ${path}: ${msg}`);
}
if (r.status === 204) return null;
return r.json();
}
async function refreshAll() {
try {
[state, presets, clients, sources] = await Promise.all([
api("GET", "/api/state"),
api("GET", "/api/presets"),
api("GET", "/api/clients"),
api("GET", "/api/sources"),
]);
render();
} catch (e) {
console.error(e);
}
}
function render() {
// header pills
const sp = $("state-pill");
sp.className = "pill " + state.playback;
sp.textContent = state.playback;
const ap = $("active-pill");
if (state.active_preset) {
const p = presets.find(x => x.id === state.active_preset);
ap.textContent = p ? p.name : state.active_preset;
ap.style.display = "";
} else {
ap.style.display = "none";
}
// active preset selector
const sel = $("active-preset");
sel.innerHTML = "";
const noneOpt = document.createElement("option");
noneOpt.value = ""; noneOpt.textContent = "— off —";
sel.appendChild(noneOpt);
for (const p of presets) {
const o = document.createElement("option");
o.value = p.id; o.textContent = p.name;
sel.appendChild(o);
}
sel.value = state.active_preset || "";
// playback buttons
$("btn-play").disabled = !state.active_preset || state.playback === "playing";
$("btn-pause").disabled = state.playback !== "playing";
$("btn-stop").disabled = !state.active_preset;
// volume slider
$("volume-label").textContent = state.volume + "%";
if (!volumeDirty) $("volume").value = state.volume;
$("volume").disabled = !state.active_preset;
// members of the active preset
const mEl = $("members");
if (!state.members || !state.members.length) {
mEl.innerHTML = '<div class="empty">No active preset</div>';
} else {
mEl.innerHTML = "";
for (const m of state.members) {
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="dot ${m.connected ? 'connected' : ''}"></span>
<span class="grow mono">${escapeHTML(m.client_id)}</span>
<span class="muted">${m.connected ? `vol ${m.volume}${m.muted ? ' · muted' : ''}` : 'offline'}</span>
`;
mEl.appendChild(div);
}
}
// discovered clients
const cEl = $("clients");
if (!clients.length) {
cEl.innerHTML = '<div class="empty">No clients discovered yet</div>';
} else {
cEl.innerHTML = "";
for (const c of clients) {
const dot = c.connected ? "connected" : "discovered";
const id = c.client_id || c.instance || "—";
const label = c.name || c.instance || id;
let badge;
if (c.source === "connected") {
badge = '<span class="badge active">connected</span>';
} else if (c.source === "roster") {
badge = '<span class="badge" title="Speaker has handshaked at least once; we know its client_id">known</span>';
} else {
badge = '<span class="badge" title="Only seen via mDNS; never handshaked, so client_id is not yet known">mDNS only</span>';
}
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="dot ${dot}"></span>
<span class="grow">
<div>${escapeHTML(label)}</div>
<div class="mono muted">${escapeHTML(id)}</div>
</span>
${badge}
`;
cEl.appendChild(div);
}
}
// presets list
const pEl = $("presets");
if (!presets.length) {
pEl.innerHTML = '<div class="empty">No presets — click "New preset" to create one.</div>';
} else {
pEl.innerHTML = "";
for (const p of presets) {
const isActive = p.id === state.active_preset;
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="grow">
<div>
${escapeHTML(p.name)}
${isActive ? '<span class="badge active">active</span>' : ''}
</div>
<div class="mono muted">${escapeHTML(p.id)} · ${escapeHTML(p.source)} · ${p.members.length} member${p.members.length === 1 ? '' : 's'}</div>
</span>
<div class="btn-group">
<button data-act="activate" data-id="${escapeAttr(p.id)}" ${isActive ? 'disabled' : ''}>Activate</button>
<button data-act="edit" data-id="${escapeAttr(p.id)}">Edit</button>
<button data-act="delete" data-id="${escapeAttr(p.id)}" class="danger">Delete</button>
</div>
`;
pEl.appendChild(div);
}
}
}
function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function escapeAttr(s) { return escapeHTML(s); }
$("active-preset").addEventListener("change", async (e) => {
try { await api("POST", "/api/active_preset", { id: e.target.value }); }
catch (err) { alert(err.message); }
refreshAll();
});
$("btn-play").onclick = async () => { await api("POST", "/api/playback", { command: "play" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-pause").onclick = async () => { await api("POST", "/api/playback", { command: "pause" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-stop").onclick = async () => { await api("POST", "/api/playback", { command: "stop" }).catch(e => alert(e.message)); refreshAll(); };
const vol = $("volume");
vol.addEventListener("input", () => { volumeDirty = true; $("volume-label").textContent = vol.value + "%"; });
vol.addEventListener("change", async () => {
try { await api("POST", "/api/volume", { volume: parseInt(vol.value, 10) }); }
catch (e) { alert(e.message); }
volumeDirty = false;
refreshAll();
});
$("btn-refresh-clients").onclick = refreshAll;
$("btn-new-preset").onclick = () => openModal(null);
document.getElementById("presets").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-act]");
if (!btn) return;
const id = btn.dataset.id;
if (btn.dataset.act === "activate") {
try { await api("POST", "/api/active_preset", { id }); } catch (err) { alert(err.message); }
refreshAll();
} else if (btn.dataset.act === "edit") {
const p = presets.find(x => x.id === id);
if (p) openModal(p);
} else if (btn.dataset.act === "delete") {
if (!confirm(`Delete preset "${id}"?`)) return;
try { await api("DELETE", "/api/presets/" + encodeURIComponent(id)); } catch (err) { alert(err.message); }
refreshAll();
}
});
function openModal(p) {
editing = p;
$("modal-title").textContent = p ? "Edit preset" : "New preset";
$("f-id").value = p ? p.id : "";
$("f-id").disabled = !!p;
$("f-name").value = p ? p.name : "";
formMembers = p ? [...p.members] : [];
// source dropdown
const src = $("f-source");
src.innerHTML = "";
const known = new Set(sources.map(s => s.name));
if (p && !known.has(p.source)) {
// preserve current source even if PipeWire doesn't currently advertise it
const o = document.createElement("option");
o.value = p.source;
o.textContent = p.source + " (not found)";
src.appendChild(o);
}
// Group monitors vs real inputs into optgroups so "stream what's
// playing" is the obvious choice.
const monitorGrp = document.createElement("optgroup");
monitorGrp.label = "Monitors (capture playback)";
const inputGrp = document.createElement("optgroup");
inputGrp.label = "Inputs (microphones, line-in)";
for (const s of sources) {
const o = document.createElement("option");
o.value = s.name;
const desc = s.description || s.name;
o.textContent = s.description ? `${desc} [${s.name}]` : s.name;
if (s.is_monitor) {
monitorGrp.appendChild(o);
} else {
inputGrp.appendChild(o);
}
}
if (monitorGrp.children.length) src.appendChild(monitorGrp);
if (inputGrp.children.length) src.appendChild(inputGrp);
if (p) src.value = p.source;
renderFormMembers();
$("modal").classList.add("open");
$("f-name").focus();
}
function closeModal() { $("modal").classList.remove("open"); editing = null; }
function renderFormMembers() {
const el = $("f-members");
el.innerHTML = "";
// Only entries with a known client_id (connected or roster) can be
// checked on. mDNS-only entries don't expose a client_id, so they
// can't be safely used as preset members — the user must wait for
// the speaker to handshake once, or paste its client_id below.
const knownIDs = new Map();
for (const c of clients) {
if (!c.client_id) continue;
if (!knownIDs.has(c.client_id)) {
knownIDs.set(c.client_id, { id: c.client_id, label: c.name || c.client_id, connected: !!c.connected });
}
}
for (const id of formMembers) {
if (!knownIDs.has(id)) knownIDs.set(id, { id, label: id, connected: false });
}
if (knownIDs.size === 0) {
el.innerHTML = '<div class="muted">No speakers have handshaked yet. Make sure they\'re on the network, or add one by client_id below.</div>';
return;
}
for (const entry of knownIDs.values()) {
const id = entry.id;
const checked = formMembers.includes(id);
const row = document.createElement("label");
row.className = "inline";
row.innerHTML = `
<input type="checkbox" data-id="${escapeAttr(id)}" ${checked ? 'checked' : ''} />
<span class="dot ${entry.connected ? 'connected' : ''}"></span>
<span class="grow">${escapeHTML(entry.label)}</span>
<span class="mono muted">${escapeHTML(id)}</span>
`;
row.querySelector("input").addEventListener("change", (e) => {
if (e.target.checked) {
if (!formMembers.includes(id)) formMembers.push(id);
} else {
formMembers = formMembers.filter(m => m !== id);
}
});
el.appendChild(row);
}
}
$("f-add-member").onclick = () => {
const input = $("f-custom-member");
const id = input.value.trim();
if (!id) return;
if (!formMembers.includes(id)) formMembers.push(id);
input.value = "";
renderFormMembers();
};
$("f-cancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
$("preset-form").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {
id: $("f-id").value.trim(),
name: $("f-name").value.trim(),
source: $("f-source").value,
members: formMembers,
};
try {
if (editing) {
await api("PUT", "/api/presets/" + encodeURIComponent(payload.id), payload);
} else {
await api("POST", "/api/presets", payload);
}
closeModal();
refreshAll();
} catch (err) {
alert(err.message);
}
});
refreshAll();
setInterval(refreshAll, 3000);
</script>
</body>
</html>