import { encodeQR } from "@paulmillr/qr"; import { html, nothing, render as litRender } from "lit-html"; /** * @import { TemplateResult } from "lit-html" */ /** * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; isDisabled?: boolean; onShowQR?: () => void; onRemove: () => void; onToggleDisabled?: () => void }} ConnectItem */ /** * @param {string} label */ function outputErrorMessage(label) { return `${label} output was not enabled!`; } /** * Wait for an output option to be registered, failing after a timeout * instead of waiting indefinitely. The option is added by the output-bundle * prelude once it is loaded, so it may not exist yet when a connect page * first asks for it. * * @param {{ waitForOption: (label: string) => Promise<{ id: string }> }} outputOrchestrator * @param {string} label * @param {number} [timeoutMs=30_000] * @returns {Promise<{ id: string }>} */ export async function waitForOutputOption( outputOrchestrator, label, timeoutMs = 30_000, ) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(outputErrorMessage(label))), timeoutMs, ); }); try { return await Promise.race([outputOrchestrator.waitForOption(label), timeout]); } finally { clearTimeout(timer); } } /** * Sets up a connect facet UI: a card with "Add audio input" and * "Use as userdata storage" buttons, a dialog with a form, and a * reactive list of configured items below a divider. * * @param {Object} config * @param {string} config.title - Card header title * @param {TemplateResult | string} config.description - Content shown on the left side * @param {TemplateResult | typeof nothing} [config.rightContent] - Extra content shown at the top of the right side * @param {TemplateResult} config.formFields - Form body content (inputs, footnotes, etc.) * @param {(mode: 'input' | 'output') => Promise} config.onSubmit * @param {boolean} [config.hasInput] - Whether to show the "Add audio input" button (default: true) * @param {boolean} [config.hasOutput] - Whether to show the "Use as userdata storage" button (default: true) * @param {TemplateResult | typeof nothing} [config.footerActions] - Extra buttons rendered in the dialog footer * @param {() => Promise} [config.onOutputActivate] - Called instead of opening the dialog when output is already configured but inactive * * @returns {{ setItems: (items: ConnectItem[]) => void, setError: (message: string | null) => void, setDialogError: (message: string | null) => void, showQR: (data: string) => void }} */ export function setup( { title, description, rightContent = nothing, formFields, footerActions = nothing, onSubmit, hasInput = true, hasOutput = true, onOutputActivate, }, ) { const main = document.querySelector("main"); if (!main) throw new Error("No
element"); litRender( html`
Diffuse

${title}

${description}
${rightContent}
${hasInput ? html` ` : nothing} ${hasOutput ? html` ` : nothing}
${formFields}
Scan to connect
`, main, ); const dialog = /** @type {HTMLDialogElement} */ (main.querySelector("#connect-dialog")); const dialogTitleEl = /** @type {HTMLElement} */ (main.querySelector("#connect-dialog-title")); const form = /** @type {HTMLFormElement} */ (main.querySelector("#connect-form")); const dialogErrorEl = /** @type {HTMLElement} */ (main.querySelector("#connect-error")); const cardErrorEl = /** @type {HTMLElement} */ (main.querySelector("#connect-card-error")); const divider = /** @type {HTMLElement} */ (main.querySelector("#connect-divider")); const list = /** @type {HTMLElement} */ (main.querySelector("#connect-list")); const outputBtn = /** @type {HTMLElement} */ (main.querySelector("#connect-add-output-btn")); /** @type {'input' | 'output'} */ let mode = "input"; /** @type {ConnectItem[]} */ let currentItems = []; /** @param {string | null} message */ const setDialogError = (message) => { dialogErrorEl.hidden = message === null; dialogErrorEl.textContent = message; }; /** @param {string | null} message */ const setError = (message) => { cardErrorEl.hidden = message === null; cardErrorEl.textContent = message; }; /** @param {'input' | 'output'} m */ const openDialog = (m) => { mode = m; dialogTitleEl.textContent = m === "input" ? "Add audio input" : "Use as userdata storage"; form.reset(); setDialogError(null); dialog.showModal(); }; if (hasInput) { main .querySelector("#connect-add-input-btn") ?.addEventListener("click", () => openDialog("input")); } main .querySelector("#connect-add-output-btn") ?.addEventListener("click", () => { if (onOutputActivate && currentItems.some((i) => i.isOutput)) { onOutputActivate(); } else { openDialog("output"); } }); main.querySelector("#connect-cancel-btn")?.addEventListener("click", () => { setDialogError(null); dialog.close(); }); const qrDialog = /** @type {HTMLDialogElement} */ (main.querySelector("#connect-qr-dialog")); const qrBody = /** @type {HTMLElement} */ (main.querySelector("#connect-qr-body")); main.querySelector("#connect-qr-close-btn")?.addEventListener("click", () => { qrDialog.close(); }); /** @param {string} data */ const showQR = (data) => { qrBody.innerHTML = encodeQR(data, "svg"); qrDialog.showModal(); }; const submitBtn = /** @type {HTMLElement} */ (main.querySelector("#connect-submit-btn")); form.addEventListener("submit", async (e) => { e.preventDefault(); setDialogError(null); submitBtn.setAttribute("disabled", ""); submitBtn.textContent = "Loading …"; try { await onSubmit(mode); dialog.close(); } catch (err) { setDialogError( err instanceof Error ? err.message : "Something went wrong", ); } finally { submitBtn.removeAttribute("disabled"); submitBtn.textContent = "Add"; } }); return { setError, setDialogError, showQR, /** * Updates the list of configured items below the divider. * Call inside an effect() for reactivity. * * @param {ConnectItem[]} items */ setItems(items) { currentItems = items; divider.hidden = items.length === 0; list.hidden = items.length === 0; if (outputBtn) outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput); litRender( html` ${items.map( ({ name, detail, isInput, isOutput, isSelectedOutput, isDisabled, onShowQR, onRemove, onToggleDisabled }, index) => html`
  • ${name} ${detail}
    ${isInput ? html`Input` : nothing} ${isOutput ? html`Output` : nothing}
  • `, )} `, list, ); }, }; }