overlaify 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/assets/overlay-spec.md +196 -0
- package/assets/sdk/v1/overlaify.js +437 -0
- package/assets/templates/be-right-back.html +123 -0
- package/assets/templates/catalog.json +348 -0
- package/assets/templates/chat-stack.html +68 -0
- package/assets/templates/death-counter.html +94 -0
- package/assets/templates/emote-wall.html +125 -0
- package/assets/templates/goal-bar.html +89 -0
- package/assets/templates/hug-command.html +151 -0
- package/assets/templates/neon-alert.html +84 -0
- package/assets/templates/newsroom-ticker.html +136 -0
- package/assets/templates/starting-soon.html +145 -0
- package/assets/templates/subathon-timer.html +127 -0
- package/assets/templates/webcam-frame.html +93 -0
- package/bin/overlaify.js +114 -0
- package/lib/commands.js +67 -0
- package/lib/dev.html +328 -0
- package/lib/dev.js +181 -0
- package/lib/publish.js +272 -0
- package/lib/starter.html +51 -0
- package/lib/validate.js +362 -0
- package/lib/ws.js +79 -0
- package/package.json +35 -4
- package/skill/SKILL.md +88 -0
- package/skill/examples/chat-stack.html +68 -0
- package/skill/examples/goal-bar.html +89 -0
- package/skill/examples/neon-alert.html +84 -0
- package/skill/references/overlay-spec.md +196 -0
package/lib/dev.html
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Overlaify dev</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0b0a10; --panel: #15131d; --line: #2a2735; --text: #f1eff7; --dim: #9a95ab;
|
|
10
|
+
--accent: #c6ff3d; --err: #ff5d73; --warn: #ffb547; --ok: #4dffa6;
|
|
11
|
+
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color-scheme: dark;
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; background: var(--bg); color: var(--text); }
|
|
15
|
+
header { display: flex; align-items: center; gap: 14px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
16
|
+
header b { font-size: 15px; }
|
|
17
|
+
header b span { color: var(--accent); }
|
|
18
|
+
#file { color: var(--dim); font-family: ui-monospace, Menlo, monospace; font-size: 13px; }
|
|
19
|
+
.pill { padding: 3px 10px; border-radius: 99px; font-size: 12px; font-weight: 600; border: 1px solid var(--line); }
|
|
20
|
+
.pill.ok { color: var(--ok); border-color: #1f5c40; }
|
|
21
|
+
.pill.err { color: var(--err); border-color: #6a2431; }
|
|
22
|
+
.pill.warn { color: var(--warn); border-color: #6a4a1c; }
|
|
23
|
+
#obs { margin-left: auto; color: var(--dim); font-size: 13px; }
|
|
24
|
+
#obs.on { color: var(--ok); }
|
|
25
|
+
main { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; padding: 20px; }
|
|
26
|
+
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
|
|
27
|
+
.stage { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; border: 1px solid var(--line);
|
|
28
|
+
background: repeating-conic-gradient(#1b1924 0 25%, #14121b 0 50%) 0 0 / 32px 32px; }
|
|
29
|
+
.stage.dark { background: #26222f; }
|
|
30
|
+
/* A frame whose color scheme differs from the overlay's is painted opaque; light matches overlays, so it stays see-through. */
|
|
31
|
+
.stage iframe { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; border: 0; transform-origin: 0 0; background: transparent; color-scheme: light; }
|
|
32
|
+
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
|
|
33
|
+
button, .key { font: inherit; font-weight: 600; color: var(--text); background: #221f2c; border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px; cursor: pointer; }
|
|
34
|
+
button:hover { border-color: #4a4560; }
|
|
35
|
+
button.primary { background: var(--accent); color: #0b0a10; border-color: var(--accent); }
|
|
36
|
+
button.secondary { background: transparent; }
|
|
37
|
+
button.danger { color: var(--err); }
|
|
38
|
+
button.armed { background: var(--err); color: #0b0a10; }
|
|
39
|
+
label.check { display: inline-flex; gap: 6px; align-items: center; color: var(--dim); margin-left: 6px; }
|
|
40
|
+
aside { display: flex; flex-direction: column; gap: 16px; }
|
|
41
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
|
42
|
+
section h2 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
|
|
43
|
+
section h3 { margin: 12px 0 6px; font-size: 12px; color: var(--dim); font-weight: 600; }
|
|
44
|
+
.issue { font-size: 13px; padding: 6px 0; border-top: 1px solid var(--line); }
|
|
45
|
+
.issue:first-of-type { border-top: 0; }
|
|
46
|
+
.issue code { font-size: 11px; color: var(--dim); }
|
|
47
|
+
.issue.err code { color: var(--err); }
|
|
48
|
+
.issue.warn code { color: var(--warn); }
|
|
49
|
+
.empty { color: var(--dim); font-size: 13px; margin: 0; }
|
|
50
|
+
.keys { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
51
|
+
.act { display: flex; gap: 6px; align-items: center; }
|
|
52
|
+
.act.wide { flex-basis: 100%; }
|
|
53
|
+
.act input, .act select { width: 90px; }
|
|
54
|
+
.field { display: grid; gap: 4px; margin-bottom: 10px; }
|
|
55
|
+
.field > span { font-size: 13px; }
|
|
56
|
+
.field small { color: var(--dim); font-size: 12px; }
|
|
57
|
+
input, select { font: inherit; color: var(--text); background: #0f0e15; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; min-width: 0; }
|
|
58
|
+
input[type=color] { width: 44px; height: 32px; padding: 2px; }
|
|
59
|
+
input[type=range] { flex: 1; }
|
|
60
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
61
|
+
.swatch { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--line); cursor: pointer; padding: 0; }
|
|
62
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
|
|
63
|
+
.seg button { border: 0; border-radius: 0; background: transparent; }
|
|
64
|
+
.seg button.on { background: var(--accent); color: #0b0a10; }
|
|
65
|
+
.url { display: flex; gap: 6px; }
|
|
66
|
+
.url input { flex: 1; font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
|
|
67
|
+
ol { margin: 8px 0 0; padding-left: 18px; color: var(--dim); font-size: 13px; }
|
|
68
|
+
#toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); background: var(--accent); color: #0b0a10; font-weight: 600;
|
|
69
|
+
padding: 9px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none; }
|
|
70
|
+
#toast.on { opacity: 1; }
|
|
71
|
+
</style>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<header>
|
|
75
|
+
<b>overlaify <span>dev</span></b>
|
|
76
|
+
<span id="file"></span>
|
|
77
|
+
<span id="spec" class="pill"></span>
|
|
78
|
+
<span id="obs">OBS: not connected</span>
|
|
79
|
+
</header>
|
|
80
|
+
<main>
|
|
81
|
+
<div>
|
|
82
|
+
<div class="stage" id="stage"><iframe id="frame" title="Overlay preview"></iframe></div>
|
|
83
|
+
<div class="bar" id="events"></div>
|
|
84
|
+
<div class="bar">
|
|
85
|
+
<label class="check"><input type="checkbox" id="auto"> Random test events</label>
|
|
86
|
+
<label class="check"><input type="checkbox" id="dark"> Dark background</label>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<aside>
|
|
90
|
+
<section><h2>Spec check</h2><div id="issues"></div></section>
|
|
91
|
+
<section><h2>Controls</h2><div id="actions"></div></section>
|
|
92
|
+
<section><h2>Settings</h2><div id="settings"></div></section>
|
|
93
|
+
<section>
|
|
94
|
+
<h2>Add to OBS</h2>
|
|
95
|
+
<div class="url"><input id="obsUrl" readonly><button id="copy">Copy</button></div>
|
|
96
|
+
<ol>
|
|
97
|
+
<li>OBS → Sources → + → Browser.</li>
|
|
98
|
+
<li>Paste the URL; width 1920, height 1080.</li>
|
|
99
|
+
<li>Controls and settings here reach it live; saving the file reloads it.</li>
|
|
100
|
+
</ol>
|
|
101
|
+
</section>
|
|
102
|
+
</aside>
|
|
103
|
+
</main>
|
|
104
|
+
<div id="toast"></div>
|
|
105
|
+
<script>
|
|
106
|
+
(function () {
|
|
107
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
108
|
+
var frame = $('frame'), state = null, saveTimer = null;
|
|
109
|
+
var EVENTS = [['follow', 'Follow'], ['sub', 'Sub'], ['gift', 'Gift sub'], ['giftbomb', 'Gift ×10', { count: 10 }], ['cheer', 'Bits'], ['raid', 'Raid'], ['chat', 'Chat']];
|
|
110
|
+
var SOUND = [
|
|
111
|
+
{ key: 'volume', label: 'Volume', type: 'range', min: 0, max: 1, step: 0.05, default: 0.25, group: 'Sound' },
|
|
112
|
+
{ key: 'mute', label: 'Mute sounds', type: 'toggle', default: false, group: 'Sound' }
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function el(tag, cls, text) {
|
|
116
|
+
var e = document.createElement(tag);
|
|
117
|
+
if (cls) e.className = cls;
|
|
118
|
+
if (text != null) e.textContent = text;
|
|
119
|
+
return e;
|
|
120
|
+
}
|
|
121
|
+
function toast(msg) {
|
|
122
|
+
var t = $('toast'); t.textContent = msg; t.classList.add('on');
|
|
123
|
+
clearTimeout(toast.t); toast.t = setTimeout(function () { t.classList.remove('on'); }, 2200);
|
|
124
|
+
}
|
|
125
|
+
function api(path, body) {
|
|
126
|
+
return fetch(path, body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {}).then(function (r) { return r.json(); });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ---------- preview ---------- */
|
|
130
|
+
function fit() { frame.style.transform = 'scale(' + ($('stage').clientWidth / 1920) + ')'; }
|
|
131
|
+
function loadFrame() { frame.src = '/overlay' + ($('auto').checked ? '?test=1' : ''); }
|
|
132
|
+
window.addEventListener('resize', fit);
|
|
133
|
+
$('auto').onchange = loadFrame;
|
|
134
|
+
$('dark').onchange = function () { $('stage').classList.toggle('dark', this.checked); };
|
|
135
|
+
|
|
136
|
+
EVENTS.forEach(function (e) {
|
|
137
|
+
var b = $('events').appendChild(el('button', null, e[1]));
|
|
138
|
+
b.onclick = function () { frame.contentWindow.postMessage({ __overlaify: 'simulate', type: e[0], data: e[2] }, '*'); };
|
|
139
|
+
});
|
|
140
|
+
window.addEventListener('message', function (e) {
|
|
141
|
+
var d = e.data;
|
|
142
|
+
if (!d || e.source !== frame.contentWindow) return;
|
|
143
|
+
if (d.__overlaify === 'simulated' && !d.handled) toast('This overlay ignores ' + d.type + ' events');
|
|
144
|
+
if (d.__overlaify === 'acted' && !d.handled) toast('No handler for “' + d.name + '” in the preview');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/* ---------- spec check ---------- */
|
|
148
|
+
function renderIssues(check) {
|
|
149
|
+
var box = $('issues'); box.textContent = '';
|
|
150
|
+
var spec = $('spec');
|
|
151
|
+
spec.className = 'pill ' + (check.errors.length ? 'err' : check.warnings.length ? 'warn' : 'ok');
|
|
152
|
+
spec.textContent = check.errors.length ? check.errors.length + ' error' + (check.errors.length > 1 ? 's' : '')
|
|
153
|
+
: check.warnings.length ? 'Passes spec · ' + check.warnings.length + ' warning' + (check.warnings.length > 1 ? 's' : '') : 'Passes spec v1';
|
|
154
|
+
if (!check.errors.length && !check.warnings.length) return box.appendChild(el('p', 'empty', 'No problems. Ready for OBS and Overlaify.'));
|
|
155
|
+
check.errors.map(function (i) { return ['err', i]; }).concat(check.warnings.map(function (i) { return ['warn', i]; })).forEach(function (x) {
|
|
156
|
+
var row = box.appendChild(el('div', 'issue ' + x[0]));
|
|
157
|
+
row.appendChild(el('code', null, x[1].code + ' '));
|
|
158
|
+
row.appendChild(document.createTextNode(x[1].message));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ---------- action keys ---------- */
|
|
163
|
+
function optionsOf(list) {
|
|
164
|
+
return (list || []).map(function (o) {
|
|
165
|
+
return o && typeof o === 'object' ? { value: String(o.value), label: String(o.label == null ? o.value : o.label) } : { value: String(o), label: String(o) };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function grouped(list) {
|
|
169
|
+
var out = [], by = {};
|
|
170
|
+
list.forEach(function (x) {
|
|
171
|
+
var g = x.group ? String(x.group) : '';
|
|
172
|
+
if (!by[g]) out.push(by[g] = { name: g, items: [] });
|
|
173
|
+
by[g].items.push(x);
|
|
174
|
+
});
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function sendAction(a, value) {
|
|
178
|
+
frame.contentWindow.postMessage({ __overlaify: 'action', name: a.name, value: value }, '*');
|
|
179
|
+
api('/api/action', { name: a.name, value: value }).then(function (d) {
|
|
180
|
+
toast((a.label || a.name) + ' → preview' + (d.to ? ' + ' + d.to + ' in OBS' : ''));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function renderActions(actions) {
|
|
184
|
+
var box = $('actions'); box.textContent = '';
|
|
185
|
+
actions = (actions || []).filter(function (a) { return a && a.name; });
|
|
186
|
+
if (!actions.length) return box.appendChild(el('p', 'empty', 'No actions in the manifest.'));
|
|
187
|
+
grouped(actions).forEach(function (g) {
|
|
188
|
+
if (g.name) box.appendChild(el('h3', null, g.name));
|
|
189
|
+
var keys = box.appendChild(el('div', 'keys'));
|
|
190
|
+
g.items.forEach(function (a) {
|
|
191
|
+
var row = keys.appendChild(el('div', 'act' + (a.input || a.wide ? ' wide' : '')));
|
|
192
|
+
var b = row.appendChild(el('button', a.style === 'secondary' || a.style === 'danger' ? a.style : 'primary', a.label || a.name));
|
|
193
|
+
var input = null, armed = null;
|
|
194
|
+
if (a.input) {
|
|
195
|
+
if (a.input.type === 'select') {
|
|
196
|
+
input = row.appendChild(el('select'));
|
|
197
|
+
optionsOf(a.input.options).forEach(function (o) { var op = input.appendChild(el('option', null, o.label)); op.value = o.value; });
|
|
198
|
+
} else {
|
|
199
|
+
input = row.appendChild(el('input'));
|
|
200
|
+
input.type = a.input.type === 'number' ? 'number' : 'text';
|
|
201
|
+
['min', 'max', 'step'].forEach(function (k) { if (a.input[k] != null) input[k] = a.input[k]; });
|
|
202
|
+
}
|
|
203
|
+
if (a.input.default != null) input.value = a.input.default;
|
|
204
|
+
if (a.input.label) input.title = a.input.label;
|
|
205
|
+
}
|
|
206
|
+
b.onclick = function () {
|
|
207
|
+
if (a.confirm && !armed) {
|
|
208
|
+
b.classList.add('armed');
|
|
209
|
+
b.textContent = typeof a.confirm === 'string' ? a.confirm : 'Sure? Press again';
|
|
210
|
+
armed = setTimeout(disarm, 3000);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
disarm();
|
|
214
|
+
sendAction(a, input ? (a.input.type === 'number' ? Number(input.value) || 0 : input.value) : undefined);
|
|
215
|
+
};
|
|
216
|
+
function disarm() { clearTimeout(armed); armed = null; b.classList.remove('armed'); b.textContent = a.label || a.name; }
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ---------- settings ---------- */
|
|
222
|
+
// Values are saved as strings, which the overlay reads with Overlaify.param(); a value equal to its default isn't saved.
|
|
223
|
+
var values = {};
|
|
224
|
+
function save() {
|
|
225
|
+
clearTimeout(saveTimer);
|
|
226
|
+
saveTimer = setTimeout(function () { api('/api/settings', { settings: values }); }, 300);
|
|
227
|
+
}
|
|
228
|
+
function setValue(s, v) {
|
|
229
|
+
v = v == null ? '' : String(v);
|
|
230
|
+
if (v === '' || v === String(s.default == null ? '' : s.default)) delete values[s.key]; else values[s.key] = v;
|
|
231
|
+
save();
|
|
232
|
+
}
|
|
233
|
+
function field(box, s) {
|
|
234
|
+
var f = box.appendChild(el('div', 'field'));
|
|
235
|
+
f.appendChild(el('span', null, s.label || s.key));
|
|
236
|
+
var cur = values[s.key] != null ? values[s.key] : (s.default == null ? '' : String(s.default));
|
|
237
|
+
var ctl = f.appendChild(el('div', 'row')), i;
|
|
238
|
+
switch (s.type) {
|
|
239
|
+
case 'toggle':
|
|
240
|
+
i = ctl.appendChild(el('input')); i.type = 'checkbox';
|
|
241
|
+
i.checked = cur === 'true' || cur === '1';
|
|
242
|
+
i.onchange = function () { setValue(s, i.checked ? 'true' : 'false'); };
|
|
243
|
+
break;
|
|
244
|
+
case 'color':
|
|
245
|
+
i = ctl.appendChild(el('input')); i.type = 'color'; i.value = /^#[0-9a-f]{6}$/i.test(cur) ? cur : '#000000';
|
|
246
|
+
i.oninput = function () { setValue(s, i.value); };
|
|
247
|
+
(s.swatches || []).forEach(function (c) {
|
|
248
|
+
var w = ctl.appendChild(el('button', 'swatch')); w.style.background = c; w.title = c;
|
|
249
|
+
w.onclick = function () { i.value = c; setValue(s, c); };
|
|
250
|
+
});
|
|
251
|
+
break;
|
|
252
|
+
case 'select':
|
|
253
|
+
i = ctl.appendChild(el('select'));
|
|
254
|
+
optionsOf(s.options).forEach(function (o) { var op = i.appendChild(el('option', null, o.label)); op.value = o.value; });
|
|
255
|
+
i.value = cur; i.onchange = function () { setValue(s, i.value); };
|
|
256
|
+
break;
|
|
257
|
+
case 'segmented':
|
|
258
|
+
var seg = ctl.appendChild(el('div', 'seg'));
|
|
259
|
+
optionsOf(s.options).forEach(function (o) {
|
|
260
|
+
var b = seg.appendChild(el('button', o.value === cur ? 'on' : null, o.label));
|
|
261
|
+
b.onclick = function () {
|
|
262
|
+
[].forEach.call(seg.children, function (c) { c.classList.remove('on'); });
|
|
263
|
+
b.classList.add('on'); setValue(s, o.value);
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
break;
|
|
267
|
+
case 'range':
|
|
268
|
+
i = ctl.appendChild(el('input')); i.type = 'range';
|
|
269
|
+
i.min = s.min; i.max = s.max; i.step = s.step || 'any'; i.value = cur;
|
|
270
|
+
var out = ctl.appendChild(el('small', null, cur + (s.unit ? ' ' + s.unit : '')));
|
|
271
|
+
i.oninput = function () { out.textContent = i.value + (s.unit ? ' ' + s.unit : ''); setValue(s, i.value); };
|
|
272
|
+
break;
|
|
273
|
+
default:
|
|
274
|
+
i = ctl.appendChild(el('input')); i.type = s.type === 'number' ? 'number' : 'text'; i.value = cur;
|
|
275
|
+
i.style.flex = '1';
|
|
276
|
+
i.oninput = function () { setValue(s, i.value); };
|
|
277
|
+
}
|
|
278
|
+
if (s.desc) f.appendChild(el('small', null, s.desc));
|
|
279
|
+
}
|
|
280
|
+
function renderSettings(manifest, saved) {
|
|
281
|
+
values = {};
|
|
282
|
+
Object.keys(saved || {}).forEach(function (k) { values[k] = saved[k]; });
|
|
283
|
+
var box = $('settings'); box.textContent = '';
|
|
284
|
+
field(box, { key: 'channel', label: 'Twitch channel', type: 'text', default: '', desc: 'Real chat, subs, bits and raids from this channel.' });
|
|
285
|
+
var list = (manifest && Array.isArray(manifest.settings) ? manifest.settings.filter(function (s) { return s && s.key; }) : []).concat(SOUND);
|
|
286
|
+
grouped(list).forEach(function (g) {
|
|
287
|
+
if (g.name) box.appendChild(el('h3', null, g.name));
|
|
288
|
+
g.items.forEach(function (s) { field(box, s); });
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ---------- state ---------- */
|
|
293
|
+
function refresh() {
|
|
294
|
+
return api('/api/state').then(function (s) {
|
|
295
|
+
var first = !state;
|
|
296
|
+
state = s;
|
|
297
|
+
$('file').textContent = s.name + ' · ' + (s.bytes / 1024).toFixed(1) + ' KB';
|
|
298
|
+
document.title = s.name + ' — Overlaify dev';
|
|
299
|
+
renderIssues(s.check);
|
|
300
|
+
renderActions(s.manifest && s.manifest.actions);
|
|
301
|
+
// Rebuilding the form while someone types in it would steal focus; it only changes when the manifest does.
|
|
302
|
+
var sig = JSON.stringify(s.manifest && s.manifest.settings);
|
|
303
|
+
if (first || sig !== refresh.sig) renderSettings(s.manifest, s.settings);
|
|
304
|
+
refresh.sig = sig;
|
|
305
|
+
presence(s);
|
|
306
|
+
if (first) { loadFrame(); fit(); }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function presence(p) {
|
|
310
|
+
var o = $('obs');
|
|
311
|
+
o.classList.toggle('on', p.overlays > 0);
|
|
312
|
+
// OBS's browser identifies itself; other copies are browser tabs opened at the OBS URL.
|
|
313
|
+
o.textContent = p.obs ? 'OBS: ' + p.obs + ' connected' : p.overlays ? 'Running in ' + p.overlays + ' browser tab' + (p.overlays > 1 ? 's' : '') : 'OBS: not connected';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
$('obsUrl').value = location.origin + '/overlay';
|
|
317
|
+
$('copy').onclick = function () { $('obsUrl').select(); navigator.clipboard.writeText($('obsUrl').value).then(function () { toast('Copied'); }); };
|
|
318
|
+
|
|
319
|
+
var es = new EventSource('/events');
|
|
320
|
+
// The preview reloads itself on 'reload'; the panels follow.
|
|
321
|
+
es.addEventListener('reload', function () { refresh(); });
|
|
322
|
+
es.addEventListener('presence', function (e) { presence(JSON.parse(e.data)); });
|
|
323
|
+
es.addEventListener('acted', function (e) { var d = JSON.parse(e.data); if (!d.handled) toast('OBS copy has no handler for “' + d.name + '”'); });
|
|
324
|
+
refresh();
|
|
325
|
+
})();
|
|
326
|
+
</script>
|
|
327
|
+
</body>
|
|
328
|
+
</html>
|
package/lib/dev.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// `overlaify dev <file>`: a local Overlaify for one overlay file.
|
|
2
|
+
// / the dev page: preview with simulated events, settings panel, action keys, spec check
|
|
3
|
+
// /overlay the overlay as OBS should load it: SDK and saved settings injected, reloads on save
|
|
4
|
+
// /api/live/ the SDK's live-control socket, so action keys reach every copy running in OBS
|
|
5
|
+
// Settings are saved next to the file in .overlaify/<name>.json.
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, watch } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { validateOverlay } from './validate.js';
|
|
11
|
+
import { acceptWebSocket } from './ws.js';
|
|
12
|
+
|
|
13
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const SDK_PATH = join(PKG, 'assets/sdk/v1/overlaify.js');
|
|
15
|
+
const DEV_PAGE = join(PKG, 'lib/dev.html');
|
|
16
|
+
const ROOM = 'local';
|
|
17
|
+
|
|
18
|
+
export function dev(file, { port = 4545, host = '127.0.0.1', channel } = {}) {
|
|
19
|
+
file = resolve(file);
|
|
20
|
+
if (!existsSync(file)) throw new Error(`No such file: ${file}`);
|
|
21
|
+
const settingsFile = join(dirname(file), '.overlaify', basename(file).replace(/\.html?$/i, '') + '.json');
|
|
22
|
+
|
|
23
|
+
const overlays = new Set(); // live sockets from running copies of the overlay
|
|
24
|
+
const streams = new Set(); // server-sent event streams: dev pages and overlays waiting to reload
|
|
25
|
+
|
|
26
|
+
const readSettings = () => {
|
|
27
|
+
try { return JSON.parse(readFileSync(settingsFile, 'utf8')); } catch { return {}; }
|
|
28
|
+
};
|
|
29
|
+
if (channel) saveSettings({ ...readSettings(), channel });
|
|
30
|
+
|
|
31
|
+
function saveSettings(values) {
|
|
32
|
+
mkdirSync(dirname(settingsFile), { recursive: true });
|
|
33
|
+
writeFileSync(settingsFile, JSON.stringify(values, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function broadcast(event, data = {}) {
|
|
37
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
38
|
+
for (const res of streams) res.write(msg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function presence() {
|
|
42
|
+
const list = [...overlays];
|
|
43
|
+
return { overlays: list.length, obs: list.filter((o) => o.obs).length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function state() {
|
|
47
|
+
const html = readFileSync(file, 'utf8');
|
|
48
|
+
const { ok, errors, warnings, manifest } = validateOverlay(html);
|
|
49
|
+
return {
|
|
50
|
+
file,
|
|
51
|
+
name: basename(file),
|
|
52
|
+
bytes: Buffer.byteLength(html),
|
|
53
|
+
check: { ok, errors, warnings },
|
|
54
|
+
manifest,
|
|
55
|
+
settings: readSettings(),
|
|
56
|
+
...presence()
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const server = createServer(async (req, res) => {
|
|
61
|
+
const url = new URL(req.url, 'http://localhost');
|
|
62
|
+
try {
|
|
63
|
+
if (req.method === 'GET' && url.pathname === '/') return send(res, 200, 'text/html', readFileSync(DEV_PAGE));
|
|
64
|
+
if (req.method === 'GET' && url.pathname === '/overlay') {
|
|
65
|
+
return send(res, 200, 'text/html', serveOverlay(readFileSync(file, 'utf8'), readSettings()), { 'cache-control': 'no-store' });
|
|
66
|
+
}
|
|
67
|
+
if (req.method === 'GET' && /^\/sdk\/(v1\/)?overlaify\.js$/.test(url.pathname)) {
|
|
68
|
+
return send(res, 200, 'text/javascript', readFileSync(SDK_PATH));
|
|
69
|
+
}
|
|
70
|
+
if (req.method === 'GET' && url.pathname === '/api/state') return sendJson(res, state());
|
|
71
|
+
if (req.method === 'POST' && url.pathname === '/api/settings') {
|
|
72
|
+
const body = await readJson(req);
|
|
73
|
+
const values = {};
|
|
74
|
+
for (const [k, v] of Object.entries(body.settings || {})) {
|
|
75
|
+
if (v !== '' && v != null) values[k] = String(v);
|
|
76
|
+
}
|
|
77
|
+
saveSettings(values);
|
|
78
|
+
broadcast('reload');
|
|
79
|
+
return sendJson(res, { settings: values });
|
|
80
|
+
}
|
|
81
|
+
if (req.method === 'POST' && url.pathname === '/api/action') {
|
|
82
|
+
const { name, value } = await readJson(req);
|
|
83
|
+
if (typeof name !== 'string' || !name) return sendJson(res, { error: 'name is required' }, 400);
|
|
84
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
85
|
+
const msg = JSON.stringify({ t: 'action', id, name, value: value ?? null });
|
|
86
|
+
for (const o of overlays) o.ws.send(msg);
|
|
87
|
+
return sendJson(res, { id, to: overlays.size });
|
|
88
|
+
}
|
|
89
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
90
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
91
|
+
res.write(`event: presence\ndata: ${JSON.stringify(presence())}\n\n`);
|
|
92
|
+
streams.add(res);
|
|
93
|
+
req.on('close', () => streams.delete(res));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
send(res, 404, 'text/plain', 'Not found');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
sendJson(res, { error: err.message }, 500);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.on('upgrade', (req, socket) => {
|
|
103
|
+
const url = new URL(req.url, 'http://localhost');
|
|
104
|
+
if (url.pathname !== `/api/live/${ROOM}` || url.searchParams.get('role') !== 'overlay') {
|
|
105
|
+
return socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
106
|
+
}
|
|
107
|
+
const entry = { obs: url.searchParams.get('obs') === '1', ws: null };
|
|
108
|
+
entry.ws = acceptWebSocket(req, socket, {
|
|
109
|
+
onMessage(ws, text) {
|
|
110
|
+
if (text === 'ping') return ws.send('pong');
|
|
111
|
+
let m;
|
|
112
|
+
try { m = JSON.parse(text); } catch { return; }
|
|
113
|
+
if (m && m.t === 'acted') broadcast('acted', { id: m.id, name: m.name, handled: !!m.handled });
|
|
114
|
+
},
|
|
115
|
+
onClose() {
|
|
116
|
+
overlays.delete(entry);
|
|
117
|
+
broadcast('presence', presence());
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
if (!entry.ws) return;
|
|
121
|
+
overlays.add(entry);
|
|
122
|
+
broadcast('presence', presence());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Editors often save by replacing the file, which ends a plain fs.watch on it; watching the folder survives that.
|
|
126
|
+
let timer = null;
|
|
127
|
+
watch(dirname(file), (event, name) => {
|
|
128
|
+
if (name !== basename(file)) return;
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
timer = setTimeout(() => broadcast('reload'), 80);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return listen(server, port, host);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Tries the next ports when one is taken.
|
|
137
|
+
function listen(server, port, host, tries = 10) {
|
|
138
|
+
return new Promise((resolvePort, reject) => {
|
|
139
|
+
server.once('error', (err) => {
|
|
140
|
+
if (err.code === 'EADDRINUSE' && tries > 1) resolvePort(listen(server, port + 1, host, tries - 1));
|
|
141
|
+
else reject(err);
|
|
142
|
+
});
|
|
143
|
+
server.listen(port, host, () => resolvePort(port));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The overlay as the hosted /o/:id serves it: its own SDK tag swapped for the local SDK, with saved settings
|
|
148
|
+
// and the live room set first. A small script reloads it when the file or its settings change.
|
|
149
|
+
export function serveOverlay(html, settings) {
|
|
150
|
+
html = html.replace(/<script[^>]*src=["'][^"']*overlaify(\.min)?\.js["'][^>]*>\s*<\/script>\n?/gi, '');
|
|
151
|
+
const cfg = Object.keys(settings).length
|
|
152
|
+
? `<script>window.OVERLAIFY_CONFIG=${JSON.stringify(settings).replace(/</g, '\\u003c')}</script>` : '';
|
|
153
|
+
const tag = cfg +
|
|
154
|
+
`<script>window.OVERLAIFY_LIVE=${JSON.stringify({ room: ROOM })}</script>` +
|
|
155
|
+
'<script src="/sdk/v1/overlaify.js"></script>' +
|
|
156
|
+
`<script>try{new EventSource('/events').addEventListener('reload',function(){location.reload()})}catch(e){}</script>`;
|
|
157
|
+
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => `${m}\n${tag}`);
|
|
158
|
+
return tag + '\n' + html;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function send(res, status, type, body, headers = {}) {
|
|
162
|
+
res.writeHead(status, { 'content-type': `${type}; charset=utf-8`, ...headers });
|
|
163
|
+
res.end(body);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sendJson(res, data, status = 200) {
|
|
167
|
+
send(res, status, 'application/json', JSON.stringify(data), { 'cache-control': 'no-store' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readJson(req) {
|
|
171
|
+
return new Promise((ok, fail) => {
|
|
172
|
+
let body = '';
|
|
173
|
+
req.on('data', (c) => {
|
|
174
|
+
body += c;
|
|
175
|
+
if (body.length > 1e6) req.destroy();
|
|
176
|
+
});
|
|
177
|
+
req.on('end', () => {
|
|
178
|
+
try { ok(body ? JSON.parse(body) : {}); } catch { fail(new Error('Invalid JSON body')); }
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|