aboutsummaryrefslogtreecommitdiffstats
path: root/static/modules/obs.js
blob: 9430ee54d42962861868b693b481ed4247f58af8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 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 {
    await fetch(`/api/obs/${action}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
  } catch {
    // the next poll shows the real state
  }
}

function bigButton(kind, name, label, on, times) {
  const b = document.createElement("button");
  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) {
    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 transport = el.querySelector(".deck-transport");
    if (!state.connected) {
      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 = "";
      }
      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 ?? []);
  },
};