// OBS as a stream-deck tab: icon-forward buttons and volume sliders, sized
// for a finger rather than a mouse.
const ICONS = {
record: '',
stream: '',
scene: '',
volume: '',
muted: '',
eye: '',
eyeOff: '',
transition: '',
};
function icon(name) {
return ``;
}
function clock(s) {
s = Math.max(0, Math.floor(s || 0));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
async function post(action, body = {}) {
try {
await fetch(`/api/obs/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
// the next poll shows the real state
}
}
function bigButton(kind, name, label, on, times) {
const b = document.createElement("button");
b.className = `deck-btn ${kind}${on ? " on" : ""}`;
b.innerHTML = `${icon(name)}${label}${times ? `${times}` : ""}`;
return b;
}
function renderTransport(box, s) {
box.textContent = "";
const rec = bigButton("rec", "record", s.recording ? "Stop" : "Record", s.recording,
s.recording ? clock(s.recordingTime) : "");
rec.onclick = () => post("record", { on: !s.recording });
const stream = bigButton("live", "stream", s.streaming ? "Stop stream" : "Stream", s.streaming);
stream.onclick = () => post("stream", { on: !s.streaming });
const trigger = bigButton("trig", "transition", "Transition", false);
trigger.onclick = () => post("trigger");
box.append(rec, stream, trigger);
}
function renderTiles(box, items, onPick) {
box.textContent = "";
for (const item of items) {
const b = document.createElement("button");
b.className = `tile${item.on ? " on" : ""}`;
b.innerHTML = `${item.icon ? icon(item.icon) : ""}`;
b.querySelector("span").textContent = item.label;
b.onclick = () => onPick(item);
box.append(b);
}
}
function renderMixer(box, inputs) {
box.textContent = "";
for (const inp of inputs) {
const row = document.createElement("div");
row.className = "chan";
const mute = document.createElement("button");
mute.className = `mute${inp.muted ? " on" : ""}`;
mute.innerHTML = icon(inp.muted ? "muted" : "volume");
mute.setAttribute("aria-label", inp.muted ? `Unmute ${inp.name}` : `Mute ${inp.name}`);
mute.onclick = () => post("mute", { input: inp.name, muted: !inp.muted });
const name = document.createElement("span");
name.className = "chan-name";
name.textContent = inp.name;
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = "1";
slider.step = "0.01";
slider.value = String(inp.volume ?? 0);
slider.setAttribute("aria-label", `${inp.name} volume`);
const pct = document.createElement("span");
pct.className = "num chan-pct";
pct.textContent = `${Math.round((inp.volume ?? 0) * 100)}%`;
slider.oninput = () => { pct.textContent = `${Math.round(slider.value * 100)}%`; };
slider.onpointerdown = () => { row.closest(".tab-content")._sliding = true; };
slider.onpointerup = () => { row.closest(".tab-content")._sliding = false; };
slider.onchange = () => post("volume", { input: inp.name, level: Number(slider.value) });
row.append(mute, name, slider, pct);
box.append(row);
}
}
export default {
title: "OBS",
tab: "OBS",
interval: 1,
mount(el) {
el.innerHTML = `
`;
},
update(state, el) {
const transport = el.querySelector(".deck-transport");
if (!state.connected) {
transport.innerHTML = `${state.error ? `OBS offline: ${state.error}` : "OBS offline"}
`;
for (const box of el.querySelectorAll(".scenes, .sources, .mixer, .transitions")) {
box.textContent = "";
}
return;
}
renderTransport(transport, state);
renderTiles(el.querySelector(".scenes"),
(state.scenes ?? []).map((name) => ({ label: name, icon: "scene", on: name === state.currentScene })),
(item) => post("scene", { scene: item.label }));
renderTiles(el.querySelector(".sources"),
(state.sources ?? []).map((src) => ({ label: src.name, icon: src.on ? "eye" : "eyeOff", on: src.on, id: src.id })),
(item) => post("source", { scene: state.currentScene, id: item.id, on: !item.on }));
renderTiles(el.querySelector(".transitions"),
(state.transitions ?? []).map((name) => ({ label: name, on: name === state.transition })),
(item) => post("transition", { name: item.label }));
if (!el._sliding) renderMixer(el.querySelector(".mixer"), state.inputs ?? []);
},
};