aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 18:17:10 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 18:17:10 +0100
commitf2bc97620545b6581f5bd4cef6c30c142881178d (patch)
tree895f65bdd0e9a309194791c2675ec77357d3e953
parent4f6df8303745d0781a7dfbaff8ce57f0a0851e30 (diff)
downloaddeck-it-f2bc97620545b6581f5bd4cef6c30c142881178d.tar.gz
deck-it-f2bc97620545b6581f5bd4cef6c30c142881178d.zip
feat: OBS stream-deck panel
-rw-r--r--static/deck.css13
-rw-r--r--static/modules/obs.js104
-rw-r--r--tests/test_frontend.py7
3 files changed, 124 insertions, 0 deletions
diff --git a/static/deck.css b/static/deck.css
index 9bbd806..c2512d5 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -64,6 +64,19 @@ button:active { background: var(--udt-surface2); }
button.on { background: var(--udt-accent); color: var(--udt-crust); border-color: var(--udt-accent); }
+.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
+.obs-scenes, .obs-sources { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
+.obs-audio .row span { flex: 1; min-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+select {
+ font: inherit;
+ color: inherit;
+ background: var(--udt-surface1);
+ border: 1px solid var(--udt-surface2);
+ border-radius: 10px;
+ padding: 8px;
+}
+
.good { color: var(--udt-green); }
.bad { color: var(--udt-red); }
.warn { color: var(--udt-yellow); }
diff --git a/static/modules/obs.js b/static/modules/obs.js
new file mode 100644
index 0000000..c6a3ebc
--- /dev/null
+++ b/static/modules/obs.js
@@ -0,0 +1,104 @@
+const clamp = (v) => Math.max(0, Math.min(1, v));
+
+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 button(label) {
+ const b = document.createElement("button");
+ b.textContent = label;
+ return b;
+}
+
+export default {
+ title: "OBS",
+ interval: 1,
+
+ mount(el) {
+ const wrap = document.createElement("div");
+ wrap.className = "obs";
+ wrap.innerHTML = `
+ <div class="row obs-controls"></div>
+ <div class="obs-scenes"></div>
+ <div class="obs-audio"></div>
+ <div class="obs-sources"></div>`;
+ el.append(wrap);
+ },
+
+ update(state, el) {
+ const controls = el.querySelector(".obs-controls");
+ const scenes = el.querySelector(".obs-scenes");
+ const audio = el.querySelector(".obs-audio");
+ const sources = el.querySelector(".obs-sources");
+
+ if (!state.connected) {
+ controls.textContent = "OBS offline";
+ scenes.textContent = audio.textContent = sources.textContent = "";
+ return;
+ }
+
+ controls.textContent = "";
+ const rec = button(state.recording ? "Stop recording" : "Record");
+ rec.classList.toggle("on", state.recording);
+ rec.onclick = () => post("record", { on: !state.recording });
+ const stream = button(state.streaming ? "Stop stream" : "Stream");
+ stream.classList.toggle("on", state.streaming);
+ stream.onclick = () => post("stream", { on: !state.streaming });
+ controls.append(rec, stream);
+
+ if (state.transitions?.length) {
+ const sel = document.createElement("select");
+ for (const t of state.transitions) {
+ const o = document.createElement("option");
+ o.value = o.textContent = t;
+ if (t === state.transition) o.selected = true;
+ sel.append(o);
+ }
+ sel.onchange = () => post("transition", { name: sel.value });
+ const trigger = button("Transition");
+ trigger.onclick = () => post("trigger");
+ controls.append(sel, trigger);
+ }
+
+ scenes.textContent = "";
+ for (const name of state.scenes ?? []) {
+ const b = button(name);
+ b.classList.toggle("on", name === state.currentScene);
+ b.onclick = () => post("scene", { scene: name });
+ scenes.append(b);
+ }
+
+ audio.textContent = "";
+ for (const inp of state.inputs ?? []) {
+ const row = document.createElement("div");
+ row.className = "row";
+ const label = document.createElement("span");
+ label.textContent = inp.name;
+ const mute = button(inp.muted ? "Unmute" : "Mute");
+ mute.classList.toggle("on", inp.muted);
+ mute.onclick = () => post("mute", { input: inp.name, muted: !inp.muted });
+ const down = button("−");
+ down.onclick = () => post("volume", { input: inp.name, level: clamp(inp.volume - 0.1) });
+ const up = button("+");
+ up.onclick = () => post("volume", { input: inp.name, level: clamp(inp.volume + 0.1) });
+ row.append(label, mute, down, up);
+ audio.append(row);
+ }
+
+ sources.textContent = "";
+ for (const s of state.sources ?? []) {
+ const b = button(s.name);
+ b.classList.toggle("on", s.on);
+ b.onclick = () => post("source", { scene: state.currentScene, id: s.id, on: !s.on });
+ sources.append(b);
+ }
+ },
+};
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index d90bf48..94ea88f 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -60,6 +60,13 @@ class Frontend(unittest.TestCase):
self.assertEqual(status, 200)
self.assertIn(b"--udt-", body)
+ def test_obs_panel_served(self):
+ status, body = self.get("/modules/obs.js")
+ self.assertEqual(status, 200)
+ text = body.decode()
+ for part in ("export default", "mount", "update"):
+ self.assertIn(part, text)
+
if __name__ == "__main__":
unittest.main()