diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-09 19:14:30 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-09 19:14:30 +0100 |
| commit | ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4 (patch) | |
| tree | 21ab542297160755f9280236e26875fb3680d780 /static/modules | |
| parent | 15e384bc1d97c84df0545555bf4c298702c9eacb (diff) | |
| download | deck-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.js | 187 |
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 ?? []); }, }; |
