Files
video-shader-toys/ui/app.js
2026-05-02 16:40:21 +10:00

213 lines
7.0 KiB
JavaScript

const shaderSelect = document.getElementById("shader-select");
const mixSlider = document.getElementById("mix-slider");
const bypassToggle = document.getElementById("bypass-toggle");
const reloadButton = document.getElementById("reload-button");
const runtimeStatus = document.getElementById("runtime-status");
const videoStatus = document.getElementById("video-status");
const compileStatus = document.getElementById("compile-status");
const parameterForm = document.getElementById("parameter-form");
let appState = null;
let websocket = null;
function createKv(target, values) {
target.innerHTML = "";
values.forEach(([key, value]) => {
const dt = document.createElement("dt");
dt.textContent = key;
const dd = document.createElement("dd");
dd.textContent = value;
target.append(dt, dd);
});
}
function postJson(path, payload) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
function renderParameters(shader) {
parameterForm.innerHTML = "";
if (!shader) {
return;
}
shader.parameters.forEach((parameter) => {
const section = document.createElement("section");
section.className = "parameter";
const label = document.createElement("label");
label.textContent = parameter.label;
section.appendChild(label);
const valueLabel = document.createElement("div");
valueLabel.className = "parameter__value";
const sendValue = (value) => {
postJson("/api/update-parameter", {
shaderId: shader.id,
parameterId: parameter.id,
value,
});
};
if (parameter.type === "float") {
const range = document.createElement("input");
range.type = "range";
range.min = parameter.min?.[0] ?? 0;
range.max = parameter.max?.[0] ?? 1;
range.step = parameter.step?.[0] ?? 0.01;
range.value = parameter.value;
const number = document.createElement("input");
number.type = "number";
number.min = range.min;
number.max = range.max;
number.step = range.step;
number.value = parameter.value;
const pair = document.createElement("div");
pair.className = "parameter__pair";
pair.append(range, number);
section.append(pair, valueLabel);
const update = (value) => {
valueLabel.textContent = Number(value).toFixed(3);
range.value = value;
number.value = value;
};
update(parameter.value);
range.addEventListener("input", () => update(range.value));
range.addEventListener("change", () => sendValue(Number(range.value)));
number.addEventListener("change", () => {
update(number.value);
sendValue(Number(number.value));
});
} else if (parameter.type === "vec2" || parameter.type === "color") {
const pair = document.createElement("div");
pair.className = "parameter__pair";
const values = parameter.value.slice();
values.forEach((component, index) => {
const input = document.createElement("input");
input.type = "number";
input.step = parameter.step?.[index] ?? 0.01;
input.min = parameter.min?.[index] ?? "";
input.max = parameter.max?.[index] ?? "";
input.value = component;
input.addEventListener("change", () => {
values[index] = Number(input.value);
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
sendValue(values);
});
pair.appendChild(input);
});
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
section.append(pair, valueLabel);
} else if (parameter.type === "bool") {
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.checked = parameter.value;
valueLabel.textContent = parameter.value ? "Enabled" : "Disabled";
toggle.addEventListener("change", () => {
valueLabel.textContent = toggle.checked ? "Enabled" : "Disabled";
sendValue(toggle.checked);
});
section.append(toggle, valueLabel);
} else if (parameter.type === "enum") {
const select = document.createElement("select");
parameter.options.forEach((option) => {
const item = document.createElement("option");
item.value = option.value;
item.textContent = option.label;
if (option.value === parameter.value) {
item.selected = true;
}
select.appendChild(item);
});
valueLabel.textContent = parameter.value;
select.addEventListener("change", () => {
valueLabel.textContent = select.value;
sendValue(select.value);
});
section.append(select, valueLabel);
}
parameterForm.appendChild(section);
});
}
function renderState(state) {
appState = state;
const shaders = state.shaders || [];
const activeShaderId = state.runtime.activeShaderId;
const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0];
shaderSelect.innerHTML = "";
shaders.forEach((shader) => {
const option = document.createElement("option");
option.value = shader.id;
option.textContent = shader.name;
if (shader.id === activeShaderId) {
option.selected = true;
}
shaderSelect.appendChild(option);
});
mixSlider.value = state.runtime.mixAmount ?? 1;
bypassToggle.checked = Boolean(state.runtime.bypass);
compileStatus.textContent = state.runtime.compileMessage || "No compiler output.";
createKv(runtimeStatus, [
["Active Shader", activeShader?.name || "None"],
["Auto Reload", state.app.autoReload ? "On" : "Off"],
["Control URL", `http://127.0.0.1:${state.app.serverPort}`],
["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"],
]);
createKv(videoStatus, [
["Signal", state.video.hasSignal ? "Present" : "Missing"],
["Mode", state.video.modeName || "Unknown"],
["Resolution", `${state.video.width || 0} x ${state.video.height || 0}`],
]);
renderParameters(activeShader);
}
async function loadInitialState() {
const response = await fetch("/api/state");
renderState(await response.json());
}
function connectWebSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
websocket = new WebSocket(`${protocol}://${location.host}/ws`);
websocket.onmessage = (event) => {
try {
renderState(JSON.parse(event.data));
} catch (error) {
console.error("Failed to parse state update", error);
}
};
websocket.onclose = () => {
setTimeout(connectWebSocket, 1000);
};
}
shaderSelect.addEventListener("change", () => {
postJson("/api/select-shader", { shaderId: shaderSelect.value });
});
mixSlider.addEventListener("change", () => {
postJson("/api/set-mix", { mixAmount: Number(mixSlider.value) });
});
bypassToggle.addEventListener("change", () => {
postJson("/api/set-bypass", { bypass: bypassToggle.checked });
});
reloadButton.addEventListener("click", () => {
postJson("/api/reload", {});
});
loadInitialState().then(connectWebSocket);