From ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 19:14:30 +0100 Subject: feat: tabbed layout with OBS as an icon-forward stream-deck --- static/modules/obs.js | 187 +++++++++++++++++++++++++++++--------------------- 1 file changed, 110 insertions(+), 77 deletions(-) (limited to 'static/modules') 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: '', + 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 { @@ -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)}${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) { - const wrap = document.createElement("div"); - wrap.className = "obs"; - wrap.innerHTML = ` -
-
-
-
`; - el.append(wrap); + el.innerHTML = ` +
+
+
+

Scenes

+

Audio

+
+
+

Sources

+

Transition

+
+
`; }, 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 = `
${state.error ? `OBS offline: ${state.error}` : "OBS offline"}
`; + 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 ?? []); }, }; -- cgit v1.2.3