601 lines
18 KiB
HTML
601 lines
18 KiB
HTML
<!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-pause">Pause</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 toggle button: pauses when playing, resumes when paused.
|
|
// Disabled when no preset is active (selector controls play/stop).
|
|
const pauseBtn = $("btn-pause");
|
|
pauseBtn.disabled = !state.active_preset;
|
|
pauseBtn.textContent = state.playback === "paused" ? "Play" : "Pause";
|
|
pauseBtn.dataset.cmd = state.playback === "paused" ? "play" : "pause";
|
|
|
|
// 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 label = m.name || m.client_id;
|
|
const showID = m.name && m.name !== m.client_id;
|
|
const div = document.createElement("div");
|
|
div.className = "row";
|
|
div.innerHTML = `
|
|
<span class="dot ${m.connected ? 'connected' : ''}"></span>
|
|
<span class="grow">
|
|
<div>${escapeHTML(label)}</div>
|
|
${showID ? `<div class="mono muted">${escapeHTML(m.client_id)}</div>` : ''}
|
|
</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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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-pause").onclick = async () => {
|
|
const cmd = $("btn-pause").dataset.cmd || "pause";
|
|
await api("POST", "/api/playback", { command: cmd }).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>
|