aboutsummaryrefslogtreecommitdiffstats
path: root/static/modules
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 19:14:30 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 19:14:30 +0100
commitffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4 (patch)
tree21ab542297160755f9280236e26875fb3680d780 /static/modules
parent15e384bc1d97c84df0545555bf4c298702c9eacb (diff)
downloaddeck-it-ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4.tar.gz
deck-it-ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4.zip
feat: tabbed layout with OBS as an icon-forward stream-deck
Diffstat (limited to 'static/modules')
-rw-r--r--static/modules/obs.js187
1 files changed, 110 insertions, 77 deletions
diff --git a/static/modules/obs.js b/static/modules/obs.js
index 7270f87..9430ee5 100644
--- a/static/modules/obs.js
+++ b/static/modules/obs.js
@@ -1,4 +1,25 @@
-const clamp = (v) => Math.max(0, Math.min(1, v));
+// OBS as a stream-deck tab: icon-forward buttons and volume sliders, sized
+// for a finger rather than a mouse.
+
+const ICONS = {
+ record: '<circle cx="12" cy="12" r="7"/>',
+ stream: '<path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16.2a5 5 0 0 1 7 0"/><circle cx="12" cy="20" r="1"/>',
+ scene: '<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 17 9 5 9-5"/>',
+ volume: '<path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
+ muted: '<path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="m22 9-6 6"/><path d="m16 9 6 6"/>',
+ eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
+ eyeOff: '<path d="m2 2 20 20"/><path d="M6.7 6.7C4.1 8.4 2 12 2 12s3.5 7 10 7c1.7 0 3.2-.4 4.5-1"/><path d="M9.9 4.2C10.6 4.1 11.3 4 12 4c6.5 0 10 7 10 7a18 18 0 0 1-2.2 3.2"/>',
+ transition: '<path d="M2 7h14l-3-3"/><path d="M22 17H8l3 3"/>',
+};
+
+function icon(name) {
+ return `<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`;
+}
+
+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 {
@@ -12,96 +33,108 @@ async function post(action, body = {}) {
}
}
-function button(label) {
+function bigButton(kind, name, label, on, times) {
const b = document.createElement("button");
- b.textContent = label;
+ b.className = `deck-btn ${kind}${on ? " on" : ""}`;
+ b.innerHTML = `${icon(name)}<span>${label}</span>${times ? `<b class="num">${times}</b>` : ""}`;
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) : ""}<span></span>`;
+ 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) {
- const wrap = document.createElement("div");
- wrap.className = "obs";
- wrap.innerHTML = `
- <div class="row obs-controls"></div>
- <div class="section-label">Scenes</div><div class="obs-scenes"></div>
- <div class="section-label">Audio</div><div class="obs-audio"></div>
- <div class="section-label">Sources</div><div class="obs-sources"></div>`;
- el.append(wrap);
+ el.innerHTML = `
+ <div class="deck">
+ <div class="deck-transport"></div>
+ <div class="deck-cols">
+ <section class="deck-block"><h3>Scenes</h3><div class="tiles scenes"></div></section>
+ <section class="deck-block"><h3>Audio</h3><div class="mixer"></div></section>
+ </div>
+ <div class="deck-cols">
+ <section class="deck-block"><h3>Sources</h3><div class="tiles sources"></div></section>
+ <section class="deck-block"><h3>Transition</h3><div class="tiles transitions"></div></section>
+ </div>
+ </div>`;
},
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");
-
- el.querySelectorAll(".section-label").forEach((label) => {
- label.style.display = state.connected ? "" : "none";
- });
+ const transport = el.querySelector(".deck-transport");
if (!state.connected) {
- controls.textContent = state.error ? `OBS offline: ${state.error}` : "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);
+ transport.innerHTML = `<div class="deck-offline">${state.error ? `OBS offline: ${state.error}` : "OBS offline"}</div>`;
+ for (const box of el.querySelectorAll(".scenes, .sources, .mixer, .transitions")) {
+ box.textContent = "";
}
- 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);
+ 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 ?? []);
},
};