${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.")}
Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}
`;
if (ready) {
const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1;
panel = await readyKeyPanel(i, hold);
if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys");
}
else if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
const tray = trayKeys.map((t) => ``).join(" ");
let note = "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `
Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "none"}
` : ""}
${adders}
${panel}
`;
wireKeyPanel(sel, restart, ready ? "ready_page.keys" : "tiles");
wireLayout(restart);
const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey();
}
// A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check.
function newReadyKey() {
const c = cfg();
dialog(`
New READY key
`, (b) => {
const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem");
kind.onchange = () => { item.hidden = kind.value !== "ready"; };
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim().toUpperCase();
if (!label) return alertBox("Give it a label.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return;
if (!ensureDraft()) return;
const keys = state.draft.config.ready_page.keys;
let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++;
const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }]
: kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : [];
keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id;
};
});
}
function takenCells(c, which) {
const taken = new Set(["0,0", "1,0", "2,0"]);
if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); }
else if (which === "ready") { for (const k of c.ready_page.keys) taken.add(String(k.pos)); taken.add(String(c.ready_page.back_pos)); }
else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); }
return taken;
}
function firstEmpty(c, which) {
const taken = takenCells(c, which);
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) if (!taken.has(`${r},${col}`)) return [r, col];
return null;
}
function parsePos(text, c, which, allowBlank) {
const m = text.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
if (!m) { if (allowBlank && !text.trim()) return null; alertBox("Row, column: two numbers, like 2, 5."); return false; }
const pos = [+m[1], +m[2]];
if (pos[0] > 3 || pos[1] > 7) { alertBox("The deck has rows 0-3 and columns 0-7."); return false; }
if (takenCells(c, which).has(String(pos))) { alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first` + (allowBlank ? ", or leave the position blank." : ".")); return false; }
return pos;
}
function wireLayout(restart) {
const move = (what, pos) => {
if (!ensureDraft()) return;
const c = state.draft.config, [kind, id] = what.split(":");
if (kind === "tile") { const t = c.tiles.find((x) => x.id === id); if (!t) return; if (pos) t.pos = pos; else delete t.pos; }
else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "jump" && pos) c.restart_page.jump_pos = pos;
else if (kind === "back" && pos) c.restart_page.back_pos = pos;
else if (kind === "dkey") { const k = c.ready_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "dback" && pos) c.ready_page.back_pos = pos;
saveDraft(); route();
};
let dragging = null;
for (const el of main.querySelectorAll("[data-drag]")) {
el.addEventListener("dragstart", (e) => { dragging = el.dataset.drag; e.dataTransfer.setData("text/plain", dragging); e.dataTransfer.effectAllowed = "move"; });
el.addEventListener("dragend", () => { dragging = null; });
}
for (const el of main.querySelectorAll("[data-drop]")) {
el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (what) move(what, el.dataset.drop.split(",").map(Number)); });
}
const tray = main.querySelector("#tray");
if (tray && editing()) {
tray.addEventListener("dragover", (e) => { e.preventDefault(); tray.classList.add("over"); });
tray.addEventListener("dragleave", () => tray.classList.remove("over"));
tray.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (!what || !what.startsWith("tile:")) return; const t = cfg().tiles.find((x) => x.id === what.slice(5)); if (t && t.kind) return alertBox("That key always has a spot on the deck."); move(what, null); });
}
for (const inp of main.querySelectorAll("input.pagenum")) inp.onchange = () => {
const n = parseInt(inp.value, 10); if (!(n >= 1)) return;
if (!ensureDraft()) return;
const c = state.draft.config;
if (inp.dataset.which === "status") { c.page = n; c.page_name = `${n}\n${(c.page_name || "\nStatus").split("\n").slice(1).join("\n") || "Status"}`; }
else { c.restart_page.page = n; c.restart_page.page_name = `${n}\n${(c.restart_page.page_name || "\nRestart").split("\n").slice(1).join("\n") || "Restart"}`; }
saveDraft(); route();
};
const hr = main.querySelector("#hasrestart"); if (hr) hr.onchange = () => {
if (!ensureDraft()) { hr.checked = !hr.checked; return; }
const c = state.draft.config;
if (!hr.checked) confirmBox("Switch the Restart page off? Its keys are removed from the settings; page " + c.restart_page.page + " stays in Companion until someone clears it.", "Switch it off", () => { delete c.restart_page; saveDraft(); location.hash = "#keys"; route(); }, null);
else { const pos = firstEmpty(c, "status"); if (!pos) return alertBox("No free spot on the Status page for the RESTART PAGE key."); c.restart_page = { page: c.page + 1, page_name: `${c.page + 1}\nRestart`, jump_pos: pos, back_pos: pos, keys: [] }; saveDraft(); location.hash = "#keys/restart"; route(); }
if (!hr.checked) hr.checked = true;
};
const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`
New key
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = slug(b.querySelector("#nlabel").value); };
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
if (!ensureDraft()) return;
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
};
});
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`
New restart key
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value;
if (!label) return;
const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return;
if (!ensureDraft()) return;
let id = "rs_" + slug(label), n = 2;
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
};
}); };
}
// A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both.
const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles);
const READY_ONLY = ["ready", "ready_manual"];
async function keyPanel(i, hold, list = "tiles") {
const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles";
const k = await api("/api/keys");
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
// A Companion connection is picked from the ones Companion has (typing still works).
const cc = editing() ? await api("/api/companion").catch(() => ({})) : {};
const connList = ``;
const fixOptions = (cur) => `${Object.entries(c.fixes).map(([id, f]) => ``).join("")}`;
const short = (text, n) => { text = String(text || ""); return text.length > n ? text.slice(0, n - 1) + "…" : text; };
let lastQ = null;
const checks = (t.checks || []).map((chk, j) => {
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
const sentence = (kt.checks[j] || {}).sentence || chk.type;
const what = chk.type === "midi_peer" ? `${chk.peer || "?"} in ${esc(chk.session || "?")}` : chk.type === "midi_wired" ? `every MIDI peer wired ${esc((chk.nets || []).join(", "))}` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40));
const q = spec.question || chk.type;
const header = q !== lastQ ? `
`;
const base = `${list}.${i}.checks.${j}.`;
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn"
? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("checklist line${hint("What the READY page looks at for this key")}`
: f === "item" && chk.type === "ready_manual"
? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[f])
: inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
if (chk.type === "remote") {
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
fields = `
`).join("")}` : ""}
${kt.says && kt.says.length ? `Words it can show ${kt.says.length}
${esc(kt.says.join(" · "))}
` : ""}
${checks ? `
${checks}
` : ""}${addMenu}${more}
`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart, list = "tiles") {
const c = cfg();
const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel);
const mine = () => keyList(state.draft.config, list)[i];
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; });
const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; mine().checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = mine(), chk = t.checks[j];
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
const ct = state.builders.checks.find((x) => x.name === tplName);
const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct);
const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) });
if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; }
t.checks[j] = r.check; saveDraft(); route();
};
for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check);
for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check);
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
}
// ---- The READY page: what each checklist key looks at, and why it is amber or red right now ----
const READY_WORDS = {
tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
lights: "The lighting cue track has a clip under every song.",
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds: "Every song has at least one slide with a background.",
led_cross: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.",
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
};
async function readyKeyPanel(i, hold) {
const c = cfg();
const snap = await api("/api/snapshot").catch(() => ({}));
const r = snap.ready || {};
const plan = r.plan ? `
Amber: still to do. Blinking red: still missing once it is due (${esc(((c.ready || {}).due || []).map((w) => w.days.join("/") + " " + w.start + "-" + w.end).join(", "))}). BOH MIX is tapped done on the deck and reset every ${esc((c.ready || {}).reset_day || "mon")}. Hold any key ${esc(hold)} to fix it or look again; hold READY to fix everything.
Click a key to see what it checks and why it is amber or red.
`;
const k = c.ready_page.keys[i], chk = (k.checks || [])[0] || {};
const item = r.items && chk.item ? r.items[chk.item] : null;
const fix = chk.fix && c.fixes[chk.fix];
let what;
if (k.kind === "ready") what = `Sums up the page: how many keys still have something to do, and which to look at first. Hold ${esc(hold)}: fix everything on the page that can be fixed.`;
else if (chk.type === "ready_manual") what = `Only a person can check this. Tap on the deck when it is done; tap again to take it back. It resets every ${esc((c.ready || {}).reset_day || "mon")}.`;
else if (chk.type === "ready") what = `${esc(READY_WORDS[chk.item] || chk.item)} Hold ${esc(hold)}: ${esc(fix && fix.action !== "recheck" ? fix.label : "look again now")}.`;
else what = `Watches ${esc((k.checks || []).map((x) => x.tag || x.msg || x.type).join(", "))}. Hold ${esc(hold)}: fix the one that dropped.`;
const faults = item && item.faults && item.faults.length
? `
${item.details.map((d) => `
${esc(d)}
`).join("")}
`
: item ? `
Nothing to do${item.info ? ` (${esc(item.info)})` : ""}.
`;
}
function restartKeyPanel(i, hold) {
const c = cfg(), r = c.restart_page.keys[i];
const f = c.fixes[r.fix] || {};
const conns = new Set(); for (const t of c.tiles) for (const ch of t.checks || []) if (ch.conn) conns.add(ch.conn);
const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
hold runs
flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}
` : "";
return `
${onOff(`restart_page.keys.${i}`, r, "this key")} ${esc(r.label)} row ${r.pos[0]}, col ${r.pos[1]}
Hold ${esc(hold)} ${esc(f.label || r.fix)}, even when the Status page says it is fine.
`; }
// ---- 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]) => `
How do I add… (everything is done here; nothing needs a terminal)
Watch a new Companion connection. Edit (top right, the PIN) › Settings › Companion. Find it marked “not watched” › + Key. When the Status page is full it asks which group key to put it in (AUDIO, SHOW…): its problems show on that key, and holding the key reconnects it. Then the badge › Apply.
Add a connection to a key that is already there. Keys › click the key › + check… › “A Companion connection is good” › pick the connection from the list › fix: “reconnect its Companion connection” › Apply.
Watch that a device is on the network. Keys › the key › + check… › “An address answers ping” › type the address and the name the key should show (it says “NAME NO PING”).
Watch a program on another computer, and restart it. Keys › the key › + check… › “A command on another computer succeeds” › built as “A program is running on a Mac” › pick the computer and the program (on a Windows PC: Custom, e.g. tasklist | find /I \"OBS.exe\"). Then fix › a fix that starts it (make one first: next line).
Make a new fix (something a hold can do). Keys › Fixes › + Fix › pick how it is built (start a Windows program, restart a Mac program, restart a helper, reopen Ableton…) and fill in the fields; the command is written for you. Then choose it as a check's “fix”, and use “Run the fix” to try it.
Let a fix run by itself on Sundays. Settings › Auto-fix › tick it, and paint the hours on the calendar. A fix that closes a running program asks first.
A fallback button (the stream audio switches to FOH Backup by itself when Studio B dies). Keys › Fixes › + Fix › built as “Press a Companion button” › page, row, column of the button in Companion (page 4, row 0, column 5 is FOH Backup) › under more, tick failover (and pick the fix that switches back as undo). Then Keys › + Key (or an existing key) › + check… for what must be alive (an address answers ping, a program is running on a Mac, a Dante device) › fix: that press. Then Settings › Auto switch: tick it, paint the hours, and tune what counts as down (how often, how many misses). The watchdog presses once per fault, within about 5 s, even while ALARMS is muted, and never switches back by itself.
Add a line to the weekly checklist. Keys › READY page › + READY key › “Tap to confirm” (a person taps it done; resets Monday), “a checklist line the page checks by itself”, or “something else” (then + check… like any key: a connection, a ping, a program). Drag it where you want it.
Change where the READY page looks. Settings › READY page: the Planning Center service type, the ProPresenter and Resolume addresses, the Tracks Mac and its projects folder, which locations get tracks, the lineup TV, the bundle folder, and when gaps turn red.
A new computer. Computers › + Computer (its log-in address), then Computers › Setup › that computer › copy its set-up script and run it there once.
Made a mistake?. Nothing is live until Apply. After Apply: History › Undo, then Apply again.
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",
"ready_page.keys", "ready_page.back_pos", "ready.prop_lists", "failover.windows", "failover.after_seconds", "failover.note_seconds"];
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"],
["failover", "Auto switch", "Press a Companion button by itself when something dies (Studio B -> FOH Backup): which button, when, and what counts as down"],
["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"],
["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"],
["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["setup", "Auto-setup", "What this Mac found, the line each computer runs to join, and the line that sets up another campus"],
["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 === "failover") body = autoSwitchHtml(c);
else if (which === "ready") body = readySettingsHtml(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 === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "setup") body = await setupHtml(c);
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")}
${onOff("ready", c.ready || {}, "the READY page")} ${(c.ready || {}).disabled ? "The READY page is off: its keys show OFF and nothing is looked at. Everything is kept." : "The READY page looks at this weekend every 2 minutes."}
When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}
${calendarHtml("ready.due", (c.ready || {}).due)}
`
+ fieldsHtml("Ready")
+ `
lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}
The keys themselves (add, move, delete, what each checks) are on Keys › READY page.
`;
}
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.
";
}
// Auto-setup: what was found and added, the join lines, and the install line for another campus.
async function setupHtml(c) {
const d = await api("/api/discover").catch(() => null);
if (!d) return "
Auto-setup is not answering.
";
const last = d.last || {}, inv = last.inventory || {};
const line = (text, h) => `
` : ""}
${inv.not_known && inv.not_known.length ? `Companion connections auto-setup has no recipe for (${inv.not_known.length})${list(inv.not_known)}
Add a key for one by hand (Keys › + Key) if it matters.
` : ""}
Computers${hint("Each computer runs the join line once: it lets this Mac log in with its key and says it is here. The next look gives its programs keys.")}
${comps}${joined}${comps || joined ? "" : "
none yet
"}
${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}
Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}
`;
}
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)}
${onOff("auto_fix", c.auto_fix, "auto-fix")} Auto-fix ${c.auto_fix.disabled ? "is off: nothing is fixed by itself (holds still work)." : "fixes the ticked fixes by itself in the painted hours."}
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}
Pressing a Companion button by itself when something dies is under Settings › Auto switch.
` + fieldsHtml("Schedules");
}
// Which keys name each fix (on a check, as hold-also-when-green, or on a restart key).
function fixUsers(c) {
const used = {};
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
for (const t of c.tiles.concat((c.ready_page || { keys: [] }).keys)) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
return used;
}
// ---- A Companion page as Companion draws it: pick a button by clicking it ----
const cssHex = (n) => (n == null || typeof n !== "number" ? null : "#" + (n & 0xffffff).toString(16).padStart(6, "0"));
async function companionPicker(box, init, onSelect) {
// init: {page, row, col}; onSelect({page, row, col, text}) on every click.
box.innerHTML = `
Companion's pages could not be read; type the numbers below instead.
`; return; }
let cur = Object.assign({}, init || {});
if (!cur.page) cur.page = pages[0].page;
const draw = async () => {
box.innerHTML = `
Companion page
Loading page ${esc(cur.page)}…
Click the button.
`;
box.querySelector(".cp-page").onchange = (e) => { cur = { page: +e.target.value }; draw(); };
const pg = await api("/api/companion/page?n=" + cur.page).catch(() => ({ buttons: [] }));
const at = {}; let maxR = 3, maxC = 7;
for (const b of pg.buttons || []) { at[b.row + "," + b.col] = b; maxR = Math.max(maxR, Math.min(b.row, 5)); maxC = Math.max(maxC, Math.min(b.col, 8)); }
let html = `
`;
for (let r = 0; r <= maxR; r++) for (let c = 0; c <= maxC; c++) {
const b = at[r + "," + c];
const sel = +cur.row === r && +cur.col === c && cur.row !== undefined && cur.row !== "";
if (!b) { html += ``; continue; }
if (["pageup", "pagedown", "pagenum"].includes(b.type)) { html += `
`).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => `
`).join(""); };
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
b.querySelector("#jsession").oninput = fill;
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; const w = wiredFor(b.querySelector("#jpeer").value.trim()); if (w && !b.querySelector("#jaddr").dataset.typed) b.querySelector("#jaddr").value = w; };
b.querySelector("#jaddr").oninput = (e) => { e.target.dataset.typed = "1"; };
fill();
b.querySelector("#dok").onclick = () => {
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
if (!session || !peer) return alertBox("A session and a peer, please.");
if (nets.length && !wired(addr)) return alertBox(`MIDI rides the wired network (${nets.join(", ")}). Give ${peer}'s wired address, like 10.1.10.206:5004: a name or a Wi-Fi address can drop mid-service.`);
if (!ensureDraft()) return;
const d = state.draft.config;
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
let target = d.tiles.find((t) => t.id === keyId);
if (!target) { let id = "midi", n = 2; while (d.tiles.some((t) => t.id === id)) id = "midi_" + n++; target = { id, label: "MIDI", checks: [] }; const pos = firstEmpty(d, "status"); if (pos) target.pos = pos; d.tiles.push(target); }
target.checks = target.checks || []; target.checks.push(chk);
saveDraft(); closeDialog(); route();
};
});
}
function jobComputer() {
dialog(`
New computer${hint("It appears on Computers; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.")}
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#jname").oninput = () => { b.querySelector("#jalias").value = slug(b.querySelector("#jname").value); };
b.querySelector("#dok").onclick = () => {
const name = b.querySelector("#jname").value.trim(), alias = b.querySelector("#jalias").value.trim(), os = b.querySelector("#jos").value, ssh = b.querySelector("#jssh").value.trim(), role = b.querySelector("#jrole").value.trim();
if (!name || !/^[a-z0-9_]+$/.test(alias)) return alertBox("A name, and a short name of lowercase letters, digits and _.");
if (!/^[^@\s]+@[^@\s]+$/.test(ssh)) return alertBox("The log-in address is user@address.");
if (cfg().hosts[alias]) return alertBox("That short name is taken.");
if (!ensureDraft()) return;
state.draft.config.hosts[alias] = { name, os, ssh, role, runs: [{ kind: "sshd" }] };
saveDraft(); closeDialog(); location.hash = "#computers/" + alias;
};
});
}
function jobPcProgram() {
const pcs = Object.entries(cfg().hosts).filter(([, h]) => h.os === "windows");
if (!pcs.length) return alertBox("No Windows PC is listed yet: add the computer first.");
const cur = (location.hash.split("/")[1] || "");
dialog(`
Program a PC can start${hint("The panel starts programs on a PC through a scheduled task (the Setup tab's script creates it). This lists the task and adds a 'start it if it is not running' fix.")}
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const host = b.querySelector("#jhost").value, name = b.querySelector("#jname").value.trim(), exe = b.querySelector("#jexe").value.trim();
if (!name || !exe) return;
if (!ensureDraft()) return;
const d = state.draft.config, task = "Start " + name;
const h = d.hosts[host]; h.runs = h.runs || []; if (!h.runs.some((r) => r.kind === "task" && r.name === task)) h.runs.push({ kind: "task", name: task, exe });
let id = slug(name), n = 2; while (d.fixes[id]) id = slug(name) + "_" + n++;
const r = await post("/api/build", { fix: { template: "win_start", params: { host, task, exe }, short: name.toUpperCase().slice(0, 12) } });
if (!r.ok) return alertBox(r.error);
d.fixes[id] = Object.assign(r.fix, { label: r.label || ("start " + name + " if it is not running") });
saveDraft(); closeDialog(); location.hash = "#keys/fixes/" + id;
};
});
}
// ---- builders: a fix or a check is made from fields ---------------------------------
function paramsHtml(tpl, params, prefix) {
const c = cfg();
return tpl.params.map((p) => {
const v = params[p.name] != null ? params[p.name] : p.default;
const id = `${prefix}-${p.name}`;
let input;
if (p.type === "host") input = ``;
else if (p.type === "bool") input = ``;
else if (p.type === "int") input = ``;
else if (p.type === "list") input = ``;
else input = ``;
return `
`;
}).join("");
}
function readParams(root, tpl) {
const out = {};
for (const p of tpl.params) {
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
if (p.type === "bool") out[p.name] = el.checked;
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
else out[p.name] = el.value.trim();
}
return out;
}
// ---- Fixes (a tab of Keys) ----------------------------------------------------------
let tplMsg = ""; // a builder's complaint, shown once after the next paint
async function fixes(rest) {
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
const used = fixUsers(c);
const rows = Object.entries(c.fixes).map(([id, f]) => `
${onOff(`fixes.${id}`, f, "this fix")}
${esc(f.short || id)}
${esc(f.label || "")}
${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}