aboutsummaryrefslogtreecommitdiffstats
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
parent15e384bc1d97c84df0545555bf4c298702c9eacb (diff)
downloaddeck-it-ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4.tar.gz
deck-it-ffd2799fafa1a34c3b75b9cb8f7f18ef58b309d4.zip
feat: tabbed layout with OBS as an icon-forward stream-deck
-rw-r--r--static/app.js132
-rw-r--r--static/deck.css124
-rw-r--r--static/index.html3
-rw-r--r--static/modules/obs.js187
-rw-r--r--tests/test_frontend.py8
5 files changed, 336 insertions, 118 deletions
diff --git a/static/app.js b/static/app.js
index c7730e3..54d4b26 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,22 +1,18 @@
-// The shell: discover modules, mount each panel as a Gridstack card, poll its
-// state. A module is static/modules/<name>.js exporting default {title,
-// interval, mount(el), update(state, el)}. Card position and size are kept in
-// this browser's localStorage.
+// The shell: discover modules, sort them into tabs, mount each panel and poll
+// its state. A module is static/modules/<name>.js exporting default {title,
+// tab, interval, mount(el), update(state, el)}. Modules without a tab go into
+// the Dashboard. Card order and size are kept in this browser's localStorage.
-const gridEl = document.getElementById("grid");
+const tabBar = document.getElementById("tabs");
+const tabBody = document.getElementById("tab-body");
const refreshers = [];
const LAYOUT_KEY = "deckit.layout";
+const TAB_KEY = "deckit.tab";
const DEFAULT_W = 6;
const DEFAULT_H = 4;
+const tabs = {};
-const grid = GridStack.init({
- column: 12,
- cellHeight: 80,
- margin: 8,
- float: true,
- handle: ".panel-drag",
- resizable: { handles: "se" },
-}, gridEl);
+let ready = false;
function loadLayout() {
try {
@@ -36,7 +32,51 @@ function saveLayout() {
}
}
-let ready = false;
+function ensureTab(name) {
+ if (tabs[name]) return tabs[name];
+ const button = document.createElement("button");
+ button.textContent = name;
+ button.onclick = () => showTab(name);
+ tabBar.append(button);
+ const section = document.createElement("section");
+ section.className = "tab-panel";
+ section.dataset.tab = name;
+ section.hidden = true;
+ tabBody.append(section);
+ tabs[name] = { button, section };
+ return tabs[name];
+}
+
+function showTab(name) {
+ for (const [n, tab] of Object.entries(tabs)) {
+ tab.section.hidden = n !== name;
+ tab.button.classList.toggle("active", n === name);
+ }
+ try {
+ localStorage.setItem(TAB_KEY, name);
+ } catch {}
+ if (name === "Dashboard") window.dispatchEvent(new Event("resize"));
+}
+
+// Dashboard is the grid; it exists before the modules so Gridstack measures a
+// visible container.
+const dashboard = ensureTab("Dashboard");
+dashboard.section.hidden = false;
+dashboard.button.classList.add("active");
+
+const gridEl = document.createElement("div");
+gridEl.id = "grid";
+gridEl.className = "grid-stack";
+dashboard.section.append(gridEl);
+
+const grid = GridStack.init({
+ column: 12,
+ cellHeight: 80,
+ margin: 8,
+ float: true,
+ handle: ".panel-drag",
+ resizable: { handles: "se" },
+}, gridEl);
grid.on("change", saveLayout);
const savedById = {};
@@ -81,33 +121,55 @@ function makeCard(name, title) {
return body;
}
-async function mountModule(name) {
- try {
- const panel = (await import(`./modules/${name}.js`)).default;
- const content = makeCard(name, panel.title ?? name);
- panel.mount?.(content);
-
- const poll = async () => {
- try {
- const r = await fetch(`/api/${name}`);
- if (r.ok) panel.update?.(await r.json(), content);
- } catch {
- // keep the last state; the next tick retries
- }
- };
- refreshers.push(poll);
- await poll();
- setInterval(poll, (panel.interval ?? 1) * 1000);
- } catch {
- // a broken panel must not take down the rest of the dashboard
- }
+async function startPoll(name, panel, el) {
+ const poll = async () => {
+ try {
+ const r = await fetch(`/api/${name}`);
+ if (r.ok) panel.update?.(await r.json(), el);
+ } catch {
+ // keep the last state; the next tick retries
+ }
+ };
+ refreshers.push(poll);
+ await poll();
+ setInterval(poll, (panel.interval ?? 1) * 1000);
+}
+
+async function mountDashboard(name, panel) {
+ const body = makeCard(name, panel.title ?? name);
+ panel.mount?.(body);
+ await startPoll(name, panel, body);
+}
+
+async function mountTab(name, panel, section) {
+ const wrap = document.createElement("div");
+ wrap.className = "tab-content";
+ section.append(wrap);
+ panel.mount?.(wrap);
+ await startPoll(name, panel, wrap);
}
async function boot() {
const r = await fetch("/api/modules");
if (!r.ok) return;
- for (const { name } of await r.json()) await mountModule(name);
+ for (const { name } of await r.json()) {
+ let panel;
+ try {
+ panel = (await import(`./modules/${name}.js`)).default;
+ } catch {
+ continue;
+ }
+ const tabName = panel.tab ?? "Dashboard";
+ try {
+ if (tabName === "Dashboard") await mountDashboard(name, panel);
+ else await mountTab(name, panel, ensureTab(tabName).section);
+ } catch {
+ // a broken panel must not take down the rest of the dashboard
+ }
+ }
ready = true;
+ const saved = localStorage.getItem(TAB_KEY);
+ showTab(tabs[saved] ? saved : "Dashboard");
}
document.addEventListener("visibilitychange", () => {
diff --git a/static/deck.css b/static/deck.css
index 5c4af84..40d6ac8 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -3,6 +3,8 @@
html, body { height: 100%; }
body {
+ display: flex;
+ flex-direction: column;
background: var(--udt-base);
color: var(--udt-text);
font: 400 14px/20px "Noto Sans", system-ui, sans-serif;
@@ -11,16 +13,32 @@ body {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
- overflow-y: auto;
- overflow-x: hidden;
+ overflow: hidden;
+}
+
+.tabs { flex: none; display: flex; gap: 6px; padding: 8px 14px 0; }
+.tabs button {
+ font: inherit;
+ color: var(--udt-subtext0);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 8px 18px;
+ cursor: pointer;
+}
+.tabs button.active {
+ color: var(--udt-text);
+ background: var(--udt-surface0);
+ border-color: var(--udt-surface1);
}
-#grid {
+#tab-body { flex: 1; min-height: 0; overflow: auto; }
+.tab-panel {
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
}
-
-.grid-stack { min-height: 100dvh; }
+.tab-panel[hidden] { display: none; }
+.tab-content { min-height: 100%; }
.panel { min-height: 0; min-width: 0; }
@@ -167,3 +185,99 @@ select {
.good { color: var(--udt-green); }
.bad { color: var(--udt-red); }
.warn { color: var(--udt-yellow); }
+
+/* OBS stream-deck tab */
+.deck { display: flex; flex-direction: column; gap: 14px; }
+.deck-transport { display: flex; gap: 12px; }
+
+.deck-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 92px;
+ padding: 12px;
+ font: inherit;
+ font-size: 17px;
+ font-weight: 600;
+ border-radius: 16px;
+ cursor: pointer;
+ background: var(--udt-surface0);
+ border: 1px solid var(--udt-surface1);
+ color: var(--udt-text);
+}
+.deck-btn .ico { width: 28px; height: 28px; }
+.deck-btn .num { font-size: 20px; }
+.deck-btn.rec.on { background: var(--udt-red); border-color: var(--udt-red); color: var(--udt-crust); }
+.deck-btn.live.on { background: var(--udt-green); border-color: var(--udt-green); color: var(--udt-crust); }
+.deck-btn:active { background: var(--udt-surface1); }
+
+.deck-cols { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; }
+@media (max-width: 900px) { .deck-cols { grid-template-columns: 1fr; } }
+
+.deck-block {
+ background: var(--udt-surface0);
+ border: 1px solid var(--udt-surface1);
+ border-radius: 16px;
+ padding: 12px;
+}
+.deck-block h3 {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--udt-overlay0);
+ margin-bottom: 10px;
+}
+
+.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
+.tile {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 74px;
+ padding: 10px;
+ font: inherit;
+ font-size: 14px;
+ text-align: center;
+ border-radius: 12px;
+ cursor: pointer;
+ overflow: hidden;
+ background: var(--udt-surface1);
+ border: 1px solid var(--udt-surface2);
+ color: var(--udt-subtext0);
+}
+.tile .ico { width: 22px; height: 22px; flex: none; }
+.tile span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tile.on {
+ background: var(--udt-accent);
+ border-color: var(--udt-accent);
+ color: var(--udt-crust);
+}
+
+.mixer { display: flex; flex-direction: column; gap: 10px; }
+.chan { display: flex; align-items: center; gap: 10px; }
+.chan .mute {
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.chan .mute .ico { width: 20px; height: 20px; }
+.chan .mute.on { background: var(--udt-red); border-color: var(--udt-red); color: var(--udt-crust); }
+.chan-name { flex: none; min-width: 84px; max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chan input[type=range] { flex: 1; min-width: 60px; accent-color: var(--udt-accent); }
+.chan-pct { flex: none; min-width: 40px; text-align: right; font-size: 12px; }
+
+.deck-offline {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ color: var(--udt-subtext0);
+}
diff --git a/static/index.html b/static/index.html
index dfb34cb..24cdf77 100644
--- a/static/index.html
+++ b/static/index.html
@@ -14,7 +14,8 @@
<title>deck-it</title>
</head>
<body>
-<main id="grid" class="grid-stack"></main>
+<nav id="tabs" class="tabs"></nav>
+<div id="tab-body"></div>
<script src="vendor/gridstack/gridstack-all.js"></script>
<script type="module" src="app.js"></script>
</body>
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 ?? []);
},
};
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index cb85f9a..0005d6c 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -78,6 +78,14 @@ class Frontend(unittest.TestCase):
for name in names:
self.assert_panel(name)
+ def test_tabbed_shell(self):
+ self.assertIn(b'id="tabs"', self.get("/")[1])
+ self.assertIn(b'id="tab-body"', self.get("/")[1])
+ app = self.get("/app.js")[1]
+ for part in (b"deckit.tab", b"ensureTab", b"showTab"):
+ self.assertIn(part, app)
+ self.assertIn(b'tab: "OBS"', self.get("/modules/obs.js")[1])
+
def test_layout_is_browser_local(self):
status, body = self.get("/app.js")
self.assertEqual(status, 200)