// deck-it - obs panel // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // 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 `${ICONS[name]}`; } 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, onLong) { 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) : ""}${onLong ? '⚙' : ""}`; b.querySelector("span").textContent = item.label; let timer = null; let longFired = false; if (onLong) { const host = () => b.closest(".tab-content"); b.addEventListener("pointerdown", () => { if (host()) host()._pressing = true; longFired = false; timer = setTimeout(() => { longFired = true; onLong(item); }, 600); }); const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } if (host()) host()._pressing = false; }; b.addEventListener("pointerup", cancel); b.addEventListener("pointercancel", cancel); b.addEventListener("pointerleave", cancel); } b.onclick = () => { if (longFired) { longFired = false; return; } 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 = `

Scenes

Audio

Sources

Transition

`; }, 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 })); if (!el._pressing) { 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 }), (item) => post("properties", { source: item.label })); } 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 ?? []); }, };