${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}
-
Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.
+
${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.")}
${prob}
${needs}${failed}${live}
-
-
Live deck ${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}
`;
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
- for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
- const mj = main.querySelector("#morejobs"); if (mj) mj.onchange = () => { const j = mj.value; mj.value = ""; if (j) job(j); };
if (!quiet) window.scrollTo(0, 0);
}
-
- // ---- Jobs: the changes people actually make, one dialog each --------------------
- const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
- const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
- async function job(which) {
- if (!editing()) return alertBox("Switch Editing on first (top right).");
- clearInterval(timer); timer = null;
- if (which === "playlist") return jobPlaylist();
- if (which === "hyperdecks") return jobHyperdecks();
- if (which === "midi") return jobMidi();
- if (which === "computer") return jobComputer();
- if (which === "pcprogram") return jobPcProgram();
- }
- function jobPlaylist() {
- const sp = cfg().spotify || {};
- dialog(`
Change the playlist
Paste the playlist link from Spotify (Share › Copy link). The SPOTIFY key, the play fix and the key guide follow it.
-
Now: ${esc(sp.name || "?")}
- `, (b) => {
- b.querySelector("#dcancel").onclick = closeDialog;
- b.querySelector("#dok").onclick = async () => {
- const info = b.querySelector("#jinfo"); info.textContent = "Looking…";
- try {
- const r = await api("/api/live/spotify?url=" + encodeURIComponent(b.querySelector("#jlink").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;
- await saveDraft();
- for (const [id, f] of Object.entries(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; }
- await saveDraft(); closeDialog(); location.hash = "#review";
- } catch (e) { info.textContent = e.message; }
- };
- });
- }
- async function jobHyperdecks() {
- let cc;
- try { cc = await api("/api/companion"); } catch (e) { return alertBox("Companion is not answering right now (" + e.message + "); try again in a moment."); }
- const c = cfg();
- const decks = (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 rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `
${k ? esc(k.label) + (k.pos ? ` (row ${k.pos[0]}, col ${k.pos[1]})` : "") : "no key"}
`; }).join("");
- if (!decks.length) return alertBox("Companion has no HyperDeck connections (module bmd-hyperdeck). Add the connection in Companion first.");
- dialog(`
Add or remove a HyperDeck
Tick the decks in use. A ticked deck gets an HD key in the REC group; an unticked one loses its key (its cell stays empty; drag another key there if you like). "May have no disk" means NO DISK is a warning, not an alarm.
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.
`;
}
- // ---- Keys ------------------------------------------------------------------
+ // ---- Keys: the two pages and the fixes -------------------------------------
async function keys(rest) {
await flush();
+ if (rest[0] === "fixes") return fixes(rest.slice(1));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
- const tabs = `
${c.restart_page ? `` : ""}
- ${editing() ? `
In Companion the Status page is page ${c.restart_page ? ` and the Restart page is page ` : ""}
` : ""}`;
- let panel = `
Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}
Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}
`;
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 will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
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 until it is over; your draft is saved. ${editing() ? '' : ""}
` : "";
- main.innerHTML = `
Review & 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 this draft was started: look again before applying." : ""}
-
Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})
${changes ? `
${changes}
` : "
No change yet.
"}
-
What will happen: ${esc(r.feel)}
- ${errors ? `
Not possible yet:
${errors}
` : ""}${warnings ? `
Worth knowing:
${warnings}
` : ""}${live}
-
-
+ 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." : ""}
+
${changes ? `
${changes}
` : "
No change yet.
"}
+
Then: ${esc(r.feel)}
+ ${errors ? `
Not possible yet:
${errors}
` : ""}${warnings ? `
${warnings}
` : ""}${live}
+
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
const go = (emergency) => async () => {
const note = main.querySelector("#note").value.trim();
@@ -788,7 +648,7 @@
main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } });
}
function applyProgress(st) {
- main.innerHTML = `
`).join("")}`; }
@@ -799,86 +659,189 @@
const facts = await api("/api/facts");
const gl = state.schema.glossary || {};
const cards = [
- ["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it 2 s and it runs the fix for what is failing."],
- ["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise press Restart the watchdog on the Overview."],
- ["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
- ["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
- ].map(([t, b]) => `
Switch Editing on (top right): the editing code and your first name.
Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.
The badge at the top counts your changes; it opens Review & Apply.
Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.
If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.
-
First-day walkthrough (10 minutes, no code needed)
Overview: the live deck. Green, red, purple.
Click a red key (or any key): read what it checks and what holding it will do.
Logs › Watchdog, filter "holds": find a hold and what it did.
Keys › Restart page: each key restarts one thing, even when green.
Overview: when alarms are on and when auto-fix may act by itself.
Computers › Map: which computer a key talks to, and what runs there.
Help › What each key does: the whole deck in words.
-
Try it (ten minutes with Editing on; nothing is live until you press APPLY)
-
Keys › HD2: tick "may have no disk" off and on; the badge counts the change; Review & Apply reads it back. Throw the draft away.
-
Keys: drag LED onto the tray, then back onto its cell. Keys › + New key "HD7", give it a check (Is a recorder OK?), delete it again.
-
Overview › Add or remove a HyperDeck: untick 5 and 6 and look at Keys (the cells stay empty); Review says the page re-imports. Throw it away.
-
Overview › Change the playlist: paste a playlist link, Look it up, see the name follow on Site setup › Spotify and the play fix. Apply if you mean it.
-
Overview › Add a MIDI link to the MIDI key for a peer that exists; Run this fix now on that check (it goes through the watchdog like a hold).
-
Fixes › VISTA: change the program file to Vista2.exe and watch the command rebuild; put it back.
-
Computers › Setup › Lighting PC: open the script, Copy; Computers › Lighting PC › Remove this computer (read what would go), Cancel.
-
Site setup › ATEM › Pick the newest from the folder (it says what differs), Discard. Site setup › Site › Export site bundle.
-
History: open the last version, Go back to this version, look at Review, Throw it away. Undo the last change does the same for the newest.
-
Make a small real change (a note on Computers › Gear), APPLY, watch the steps, then Undo it and APPLY again.
-
The words a key can show
${glossary}
-
Things only a person can do
Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), 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 for the saved switcher files.
Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.
-
Companion facts (found by trying)
${(facts.facts || []).map(([q, a]) => `
${esc(q)}
${esc(a)}
`).join("")}
`;
+ ["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("")}
`;
}
- // ---- Site setup -----------------------------------------------------------------
- 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"];
- async function site(rest) {
+ // ---- 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 sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
- const which = sections[rest[0]] ? rest[0] : "site";
- const tabs = Object.entries(sections).map(([k, n]) => ``).join("");
- const screen = sections[which];
+ 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 = `
` : "";
- if (which === "site") body += await programHtml();
- if (which === "companion") body = (await namesHtml()) + (await companionHtml()) + body;
- if (which === "schedules") body = schedulesHtml(c) + body;
- if (which === "network-dante") body += await danteHtml(c);
- if (which === "spotify") body = spotifyHtml(c) + body;
- if (which === "atem") body = (await atemHtml(c)) + body;
- main.innerHTML = `
Site setup
Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced.${editing() ? "" : " Switch Editing on to change anything."}
Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.
-
Auto-fix
${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}
${fixes}
-
Changes wait
${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}
`;
+ 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() ? `` : ""}
Copy the new folder to this Mac with scp as before, then press the button: install.sh runs by itself (the config stays as it is), the watchdog restarts, this page comes back.
-
${p.job === "running" ? " install.sh is running…" : ""}
A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/, or feed it to another campus's page.
New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.
${!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})
A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.
+ 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) {
@@ -892,12 +855,11 @@
}
async function companionHtml() {
const cc = await api("/api/companion");
- const lights = Object.entries(cc.lights || {}).map(([k, v]) => `
A new saved switcher file is waiting for Apply. ${editing() ? `` : ""}
` : "";
- return `
The saved switcher file
The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.
The folder is there but this page cannot read it (macOS keeps background programs out of cloud folders, and this Mac's own login key is not accepted on this Mac). Use Upload a saved file, or let the panel's key log in to this Mac: Computers › Setup › ${esc((cfg().site || {}).ingest_name || "the Ingest Mac")}.
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 wireSite() {
+ 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 b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
- for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
- for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); 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; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = 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 or URI."); };
+ 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 Fixes, then Review & Apply."}`); location.hash = "#computers"; } 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 () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } 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."); return; }
+ 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.")}
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) {
@@ -969,7 +1031,7 @@
else if (p.type === "int") input = ``;
else if (p.type === "list") input = ``;
else input = ``;
- return `
- Status words, the same everywhere:OK
- Problem (with the reason)
- Can't see from here – ask at the computer
+ Problem
+ Can't see from hereNot needed
- Done by hand (who, when)
+ Done by hand
+ Watchdog down (every key purple)