${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); }
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.
`).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) {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
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 ${c.tiles[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const id = cc.tiles[i].id; cc.tiles.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = "#keys"; });
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; state.draft.config.tiles[i].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 = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ 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 = state.draft.config.tiles[i], 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: "Resolume is on the Service deck and a clip is playing on the cross.",
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(", "))}). Tap keys (BOH MIX, MICS, STAGE) are 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 `
${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]) => `
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"],
["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"],
["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 === "outlets") body = await outletsHtml();
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 }; });
}
const h12 = (hhmm) => { const m = toMin(hhmm); if (m == null) return hhmm || ""; const h = Math.floor(m / 60) % 24, mm = m % 60; const ap = h < 12 ? "am" : "pm"; return `${h % 12 || 12}${mm ? ":" + String(mm).padStart(2, "0") : ""} ${ap}`; };
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 += `
SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}${editing() ? `` : ""}
${o.configured ? "" : `how to get the keys
iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).
In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).
Overview tab: copy Access ID and Access Secret here.
`}
outlet
plug
now
${rows || "
none yet
"}
${editing() && o.configured ? `` : ""}
${used ? `
Used by fixes: ${used}
` : ""}
A fix "Power-cycle a smart outlet" (Keys › Fixes) cuts the power only when the device does not answer, never while a HyperDeck is recording.
`;
}
async function outletDialog() {
let devs = [];
try { const r = await api("/api/outlets/devices"); if (!r.ok) return alertBox(r.error); devs = r.devices; } catch (e) { return alertBox(e.message); }
devs.sort((a, b) => (b.online - a.online) || String(a.name).localeCompare(String(b.name)));
dialog(`
${!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);
const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } };
const oa = q("#oadd"); if (oa) oa.onclick = outletDialog;
for (const b of main.querySelectorAll("[data-oswitch]")) b.onclick = () => { const [n, on] = b.dataset.oswitch.split("|"); confirmBox(`Switch the ${n} outlet ${on === "1" ? "on" : "OFF"}?${on === "1" ? "" : " Whatever is plugged into it loses power."}`, on === "1" ? "On" : "Off", async () => { try { const r = await post("/api/outlets/switch", { name: n, on: on === "1" }); if (!r.ok) alertBox(r.error); setTimeout(route, 1500); } catch (e) { alertBox(e.message); } }); };
for (const b of main.querySelectorAll("[data-oremove]")) b.onclick = () => confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`, "Remove", async () => { try { await post("/api/outlets", { name: b.dataset.oremove, remove: true }); route(); } catch (e) { alertBox(e.message); } });
for (const td of main.querySelectorAll("[id^='ost-']")) api("/api/outlets/status?name=" + encodeURIComponent(td.id.slice(4))).then((r) => { td.innerHTML = r.ok ? `${r.on ? "on" : "off"}` : `?`; }).catch(() => {});
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.")}