diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 132 | ||||
| -rw-r--r-- | static/deck.css | 124 | ||||
| -rw-r--r-- | static/index.html | 3 | ||||
| -rw-r--r-- | static/modules/obs.js | 187 |
4 files changed, 328 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 ?? []); }, }; |
