${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") : "Everything is fine"}${hint("The red keys, in words. Hold the key on the deck, or open it.")}
hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
` : "";
const pos = `more
id
${esc(t.id)}
${editing() && !t.kind ? `
position${hint("row, column; blank = inside its group only")}
` : ""}`;
return `
${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}
${kt.says && kt.says.length ? `
Can say${esc(kt.says.join(", "))}
` : ""}
Hold ${esc(hold)} ${esc(kt.holding)}${t.hold_always ? " also when green" : ""}
`).join("");
const live = r.live && r.live.live ? `
Live now: ${esc(r.live.reason)}. Apply waits; your draft is saved. ${editing() ? '' : ""}
` : "";
main.innerHTML = `
Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press APPLY.")}
Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "The live settings changed since: look again before applying." : ""}
`; }
// ---- Help ------------------------------------------------------------------
async function help() {
const facts = await api("/api/facts");
const gl = state.schema.glossary || {};
const cards = [
["Red key", "It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."],
["Purple keys", "The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."],
["Which computer or device?", "Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."],
["Changing something", "Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."],
].map(([t, b]) => `
Try it (ten minutes with Editing on; nothing is live until APPLY)
Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.
Settings › Playlist: paste a link, Look it up; the name follows everywhere.
Settings › Auto-fix: paint Sunday morning on the calendar.
Settings › MIDI links: add one to the MIDI key; Run the fix on that check.
Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.
Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).
Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.
A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.
The words a key can show${glossary}Only a person can
Run the set-up script on a new computer, allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.
Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.
Type the Netgear login into the keychain; keep Google Drive signed in.
Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.
Companion facts (found by trying)
${(facts.facts || []).map(([q, a]) => `
${esc(q)}
${esc(a)}
`).join("")}
`;
}
// ---- Settings: one page, every section ------------------------------------------
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"];
const SECTIONS = [
["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"],
["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"],
["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"],
["hyperdecks", "HyperDecks", "Which decks are in use"],
["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"],
["computers", "Computers", "Every computer the panel talks to"],
["dante", "Dante", "The devices the DANTE key expects"],
["switcher", "Switcher", "The saved ATEM file the panel keeps the switcher in line with"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
["program", "Program & campus", "Update the code, export or start a campus"],
["advanced", "Advanced", "Every number and name, by screen"],
];
async function settings(rest) {
await flush();
const which = SECTIONS.some(([k]) => k === rest[0]) ? rest[0] : "playlist";
const c = cfg();
const nav = `
`;
const sec = SECTIONS.find(([k]) => k === which);
let body = "";
if (which === "playlist") body = spotifyHtml(c);
else if (which === "autofix") body = autofixHtml(c);
else if (which === "wait") body = `
A recording HyperDeck always counts, whatever the clock says.
`;
else if (which === "hyperdecks") body = await hyperdecksHtml(c);
else if (which === "midi") body = midiHtml(c);
else if (which === "computers") body = computersHtml(c);
else if (which === "dante") body = await danteHtml(c) || "
No DANTE key.
";
else if (which === "switcher") body = await atemHtml(c) + fieldsHtml("ATEM");
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml();
main.innerHTML = `
Settings${editing() ? "" : hint("Switch Editing on (Edit, top right) to change anything")}
`;
}
function advancedHtml() {
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))];
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `
${esc(s)}
${h}` : ""; }).join("") || "
Nothing.
";
}
function siteHtml(c) {
const f = F("site.editing_code");
return `
${inputHtml("site.editing_code", Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}
` + fieldsHtml("Site");
}
function spotifyHtml(c) {
const s = c.spotify || {};
return `
playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")} ${editing() ? `` : ""}
Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}
${fixes}
` + fieldsHtml("Schedules");
}
// --- the weekly calendar: half-hour cells; click or drag to paint ---
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const toMin = (hhmm) => { const m = String(hhmm || "").match(/^(\d{1,2}):(\d{2})$/); return m ? +m[1] * 60 + +m[2] : null; };
const toHHMM = (min) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
function cellsOf(windows) {
const on = new Set();
for (const w of windows || []) {
const s = toMin(w.start), e = toMin(w.end); if (s == null || e == null) continue;
for (const d of w.days || []) {
const di = DAYS.indexOf(d); if (di < 0) continue;
if (e > s) { for (let m = s; m < e; m += 30) on.add(di + ":" + m); }
else { for (let m = s; m < 1440; m += 30) on.add(di + ":" + m); for (let m = 0; m < e; m += 30) on.add(((di + 1) % 7) + ":" + m); }
}
}
return on;
}
function windowsOf(on) {
const runs = [];
for (let d = 0; d < 7; d++) { let s = null; for (let m = 0; m <= 1440; m += 30) { const here = m < 1440 && on.has(d + ":" + m); if (here && s == null) s = m; if (!here && s != null) { runs.push([d, s, m]); s = null; } } }
for (let i = 0; i < runs.length; i++) { const r = runs[i]; if (r[2] !== 1440) continue; const j = runs.findIndex((x) => x[0] === (r[0] + 1) % 7 && x[1] === 0); if (j >= 0 && j !== i && runs[j][2] < 1440) { r[2] = runs[j][2] + 1440; runs.splice(j, 1); if (j < i) i--; } }
const byTime = {};
for (const [d, s, e] of runs) { const k = toHHMM(s) + "-" + toHHMM(e >= 1440 ? e - 1440 : e); (byTime[k] = byTime[k] || []).push(DAYS[d]); }
return Object.entries(byTime).map(([k, days]) => { const [start, end] = k.split("-"); return { days: days.sort((a, b) => DAYS.indexOf(a) - DAYS.indexOf(b)), start, end: end === "00:00" ? "24:00" : end }; });
}
function calendarHtml(path, windows) {
const on = cellsOf(windows);
const SHOW = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
let grid = `` + SHOW.map((d) => `
${d[0].toUpperCase() + d.slice(1)}
`).join("");
for (let m = 0; m < 1440; m += 30) {
grid += `
${m % 120 === 0 ? toHHMM(m) : ""}
`;
for (const name of SHOW) { const d = DAYS.indexOf(name); grid += ``; }
}
const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(w.start)}–${esc(w.end)}`).join(" · ") : "never";
return `
${grid}
${words}${editing() ? " Click or drag to paint; drag over painted cells to clear." : ""}
`;
}
function wireCalendars() {
for (const cal of main.querySelectorAll(".cal[data-calpath]")) {
if (!editing()) continue;
let painting = null;
const apply = (cell) => { if (!cell || !cell.dataset || !cell.dataset.cell) return; cell.classList.toggle("on", painting); };
cal.addEventListener("mousedown", (e) => { const cell = e.target.closest(".c"); if (!cell) return; e.preventDefault(); painting = !cell.classList.contains("on"); apply(cell); });
cal.addEventListener("mouseover", (e) => { if (painting == null) return; apply(e.target.closest(".c")); });
const finish = () => {
if (painting == null) return; painting = null;
const on = new Set([...cal.querySelectorAll(".c.on")].map((x) => x.dataset.cell));
setPath(cal.dataset.calpath, windowsOf(on)); route();
};
cal.addEventListener("mouseup", finish); cal.addEventListener("mouseleave", finish);
cal.addEventListener("touchstart", (e) => { const cell = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (!cell || !cell.dataset.cell) return; painting = !cell.classList.contains("on"); apply(cell); }, { passive: true });
cal.addEventListener("touchmove", (e) => { if (painting == null) return; apply(document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)); }, { passive: true });
cal.addEventListener("touchend", finish);
}
}
async function hyperdecksHtml(c) {
let cc = null;
try { cc = await api("/api/companion"); } catch (e) { cc = null; }
const decks = cc ? (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck") : [];
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
const known = new Set(decks.map((d) => d.label));
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.conn); }
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `
${d.missing ? ` not in Companion` : ""}
${k ? `${esc(k.label)}` : "no key"}
`; }).join("");
return `
${!cc ? "
Companion is not answering; the list is from the keys.
" : ""}
in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}
${!m.session_seen ? `session not seen${m.host ? " on " + esc((cfg().hosts[m.host] || {}).name || m.host) : ""}${m.sessions_seen.length ? " (seen: " + esc(m.sessions_seen.join(", ")) + ")" : ""}` : `peer not in the session now${m.peers_seen.length ? " (in it: " + esc(m.peers_seen.join(", ")) + ")" : ""}`}
change it on the key
`).join("");
return `
Names the keys use that are not here (${n.missing})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}
name
used by
${conns}${dante}${midi}
`;
}
function renameConn(from, to) {
const d = state.draft.config;
for (const t of d.tiles) for (const c of t.checks || []) if (c.conn === from) c.conn = to;
if (d.restart_page) for (const k of d.restart_page.keys) k.watch = (k.watch || []).map((w) => (w === "conn:" + from ? "conn:" + to : w));
for (const f of Object.values(d.fixes)) if (f.conn === from) f.conn = to;
}
function renameDante(from, to) {
for (const t of state.draft.config.tiles) for (const c of t.checks || []) if (c.type === "dante" && c.devices && from in c.devices) { c.devices[to] = c.devices[from]; delete c.devices[from]; if (c.fix && typeof c.fix === "object" && from in c.fix) { c.fix[to] = c.fix[from]; delete c.fix[from]; } }
}
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}`).join("");
const conns = (cc.connections || []).map((r) => `
The folder is there but this page cannot read it. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).
` : ""}
`;
}
function wireSettings(which) {
const q = (s) => main.querySelector(s);
wireCalendars();
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const d = state.draft.config, id = cb.dataset.autofix, f = d.fixes[id]; const set = new Set(d.auto_fix.fixes || []); if (cb.checked) { set.add(id); if (closes(f)) f.restart_ok = true; if (f.never_auto) delete f.never_auto; } else set.delete(id); setPath("auto_fix.fixes", [...set]); };
for (const b of main.querySelectorAll("[data-rmcheck]")) b.onclick = () => confirmBox("Remove this MIDI link from its key?", "Remove", () => { if (!ensureDraft()) return; const [tid, j] = b.dataset.rmcheck.split("|"); const t = state.draft.config.tiles.find((x) => x.id === tid); t.checks.splice(+j, 1); saveDraft(); route(); });
const ds = q("#decksave"); if (ds) ds.onclick = () => applyDecks(main);
const dc = () => danteCheck(state.draft.config);
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) { c.spotify.name = r.name; q("#spname").value = r.name; } q("#splink").value = r.uri; await saveDraft(); for (const f of Object.values(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; } saveDraft(); info.textContent = r.name ? `${r.name}: ${r.songs} songs.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link."); };
const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and the Companion names, then Apply."}`); location.hash = "#settings/companion"; } catch (e) { alertBox(e.message); } }); };
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
const an = q("#atemnewest"); if (an) an.onclick = async () => { an.disabled = true; an.textContent = "Reading…"; try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); route(); } };
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
function showAtem(r) {
if (!r.ok) { alertBox(r.error || "Not taken."); route(); return; }
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
route();
}
}
function applyDecks(root) {
if (!ensureDraft()) return;
const d = state.draft.config;
const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec");
const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck");
for (const cb of root.querySelectorAll("[data-deck]")) {
const conn = cb.dataset.deck, nodisk = root.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked;
const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; }
else if (cb.checked) {
const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++;
const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] };
const pos = firstEmpty(d, "status"); if (pos) t.pos = pos;
d.tiles.push(t); if (rec) rec.members.push(id);
} else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); }
}
saveDraft(); route();
}
// ---- Jobs: the dialogs that add something ----------------------------------------
async function job(which) {
if (!needEditing()) return;
clearInterval(timer); timer = null;
if (which === "midi") return jobMidi();
if (which === "computer") return jobComputer();
if (which === "pcprogram") return jobPcProgram();
}
async function jobMidi() {
const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} }));
const c = cfg();
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
dialog(`
MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}