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
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Overlaify SDK v0.1.0 — https://www.overlaify.com
|
|
3
|
+
* Connects an OBS Browser Source overlay to live-stream events.
|
|
4
|
+
* Runtime for Overlay Spec v1 (docs/overlay-spec.md), served at /sdk/v1/overlaify.js. A later spec that
|
|
5
|
+
* would break v1 overlays gets its own /sdk/vN/ path; this one keeps working for them.
|
|
6
|
+
*
|
|
7
|
+
* Sources: each { platform, channel } in OVERLAIFY_CONFIG.sources, plus ?channel= as a Twitch source.
|
|
8
|
+
* A connector per platform turns that platform's events into the events below, tagged with `platform`.
|
|
9
|
+
* Platforms: twitch (chat, anonymous and read-only).
|
|
10
|
+
*
|
|
11
|
+
* URL params:
|
|
12
|
+
* ?channel=name connect to this Twitch channel's chat
|
|
13
|
+
* ?test=1 emit random simulated events (for previews)
|
|
14
|
+
* ?mute=1 silence Overlaify.chime()
|
|
15
|
+
* anything else read with Overlaify.param(name, fallback)
|
|
16
|
+
* Settings saved for an installed overlay arrive as window.OVERLAIFY_CONFIG and act as defaults.
|
|
17
|
+
* window.OVERLAIFY_LIVE = { room } (set by /o/:id) connects it to its live control page, /c/:room,
|
|
18
|
+
* whose action keys arrive as Overlaify.dispatch(name, value, { live: true }). Previews in a frame don't connect.
|
|
19
|
+
*
|
|
20
|
+
* Events: chat, follow, sub, gift, giftbomb, cheer, tip, raid ('*' = all, each once)
|
|
21
|
+
* tip is money from any platform; Twitch bits fire cheer and tip, and reach '*' as the cheer.
|
|
22
|
+
* Lifecycle: connected, disconnected ({ platform, channel })
|
|
23
|
+
*
|
|
24
|
+
* An overlay can describe itself in <script type="application/json" id="overlaify-manifest">:
|
|
25
|
+
* { "settings": [{ key, label, type: text|number|color|toggle|select|segmented|range, default, desc?, group?,
|
|
26
|
+
* options? (select/segmented: values or { value, label }), min?, max?, step?, unit? (range),
|
|
27
|
+
* swatches? (color: preset hex colors) }],
|
|
28
|
+
* "actions": [{ name, label, group?, style?: primary|secondary|danger, confirm?: true|text, wide?,
|
|
29
|
+
* input?: { type: number|text|select, label, default, min?, max?, step?, options? } }] }
|
|
30
|
+
* Settings are its starting state (read them with Overlaify.param). Actions change that state while it
|
|
31
|
+
* runs: Overlaify.action('pause', fn) handles one, Overlaify.dispatch('pause') runs it (e.g. from chat).
|
|
32
|
+
*/
|
|
33
|
+
(function () {
|
|
34
|
+
'use strict';
|
|
35
|
+
if (window.Overlaify) return;
|
|
36
|
+
|
|
37
|
+
// Saved settings (window.OVERLAIFY_CONFIG, set by /o/:id) are the defaults; the URL's own params win.
|
|
38
|
+
// Structured config (sources) stays out of params.
|
|
39
|
+
var params = new URLSearchParams(location.search);
|
|
40
|
+
var saved = window.OVERLAIFY_CONFIG || {};
|
|
41
|
+
Object.keys(saved).forEach(function (k) {
|
|
42
|
+
if (!params.has(k) && (saved[k] === null || typeof saved[k] !== 'object')) params.set(k, saved[k]);
|
|
43
|
+
});
|
|
44
|
+
var listeners = {};
|
|
45
|
+
var seq = 0;
|
|
46
|
+
var inFrame = false;
|
|
47
|
+
try { inFrame = window.top !== window.self; } catch (e) { inFrame = true; }
|
|
48
|
+
|
|
49
|
+
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
50
|
+
function uid() { return Date.now().toString(36) + '-' + (++seq).toString(36); }
|
|
51
|
+
function cleanChannel(s) { return String(s || '').toLowerCase().replace(/^#/, '').trim(); }
|
|
52
|
+
|
|
53
|
+
// Twitch bits are both a cheer and a tip. '*' gets them once, as the cheer, as it always has.
|
|
54
|
+
function emitBits(emit, d) {
|
|
55
|
+
var bits = Number(d.bits) || 0;
|
|
56
|
+
var tip = Object.assign({}, d, {
|
|
57
|
+
amount: bits, currency: 'bits', kind: 'bits', usd: bits / 100,
|
|
58
|
+
display: bits.toLocaleString('en-US') + (bits === 1 ? ' bit' : ' bits')
|
|
59
|
+
});
|
|
60
|
+
delete tip.bits;
|
|
61
|
+
return { cheer: emit('cheer', d), tip: emit('tip', tip, { wildcard: false }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeHTML(s) {
|
|
65
|
+
return String(s == null ? '' : s)
|
|
66
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// emotes: { "25": [[0,4],[12,16]] } (Twitch code-point offsets)
|
|
71
|
+
function renderMessage(text, emotes) {
|
|
72
|
+
var chars = Array.from(String(text || ''));
|
|
73
|
+
var repl = [];
|
|
74
|
+
if (emotes) {
|
|
75
|
+
Object.keys(emotes).forEach(function (id) {
|
|
76
|
+
(emotes[id] || []).forEach(function (r) { repl.push({ s: r[0], e: r[1], id: id }); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
repl.sort(function (a, b) { return a.s - b.s; });
|
|
80
|
+
var out = '', i = 0;
|
|
81
|
+
repl.forEach(function (r) {
|
|
82
|
+
if (r.s < i || r.s >= chars.length) return;
|
|
83
|
+
out += escapeHTML(chars.slice(i, r.s).join(''));
|
|
84
|
+
var name = chars.slice(r.s, r.e + 1).join('');
|
|
85
|
+
out += '<img class="ov-emote" alt="' + escapeHTML(name) + '" src="https://static-cdn.jtvnw.net/emoticons/v2/' +
|
|
86
|
+
encodeURIComponent(r.id) + '/default/dark/2.0" style="height:1.4em;vertical-align:middle">';
|
|
87
|
+
i = r.e + 1;
|
|
88
|
+
});
|
|
89
|
+
return out + escapeHTML(chars.slice(i).join(''));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var O = {
|
|
93
|
+
version: '0.1.0',
|
|
94
|
+
spec: 1,
|
|
95
|
+
params: params,
|
|
96
|
+
channel: cleanChannel(params.get('channel')),
|
|
97
|
+
sources: [],
|
|
98
|
+
isTest: params.get('test') === '1',
|
|
99
|
+
connected: false,
|
|
100
|
+
|
|
101
|
+
param: function (name, fallback) {
|
|
102
|
+
var v = params.get(name);
|
|
103
|
+
if (v === null || v === '') return fallback;
|
|
104
|
+
if (typeof fallback === 'number') { var n = Number(v); return isNaN(n) ? fallback : n; }
|
|
105
|
+
if (typeof fallback === 'boolean') return v === '1' || v === 'true';
|
|
106
|
+
return v;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
on: function (type, fn) {
|
|
110
|
+
(listeners[type] = listeners[type] || []).push(fn);
|
|
111
|
+
return function () { O.off(type, fn); };
|
|
112
|
+
},
|
|
113
|
+
off: function (type, fn) {
|
|
114
|
+
listeners[type] = (listeners[type] || []).filter(function (f) { return f !== fn; });
|
|
115
|
+
},
|
|
116
|
+
// opts.wildcard === false keeps the event from '*' (it duplicates one '*' already got).
|
|
117
|
+
emit: function (type, data, opts) {
|
|
118
|
+
var ev = Object.assign({ type: type, id: uid(), timestamp: Date.now(), user: '', name: '', color: null }, data || {});
|
|
119
|
+
var own = type === 'connected' || type === 'disconnected' || type.indexOf('action:') === 0 || (opts && opts.wildcard === false);
|
|
120
|
+
var fns = (listeners[type] || []).concat(own ? [] : (listeners['*'] || []));
|
|
121
|
+
fns.forEach(function (fn) { try { fn(ev); } catch (e) { console.error('[overlaify] handler error', e); } });
|
|
122
|
+
return ev;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Sequential runner: handler(ev) may return a Promise or a number (ms to wait).
|
|
126
|
+
queue: function (handler, opts) {
|
|
127
|
+
var gap = (opts && opts.gap != null) ? opts.gap : 300;
|
|
128
|
+
var items = [], busy = false;
|
|
129
|
+
function pump() {
|
|
130
|
+
if (busy) return;
|
|
131
|
+
busy = true;
|
|
132
|
+
(function next() {
|
|
133
|
+
if (!items.length) { busy = false; return; }
|
|
134
|
+
var ev = items.shift();
|
|
135
|
+
Promise.resolve().then(function () { return handler(ev); })
|
|
136
|
+
.then(function (r) { return typeof r === 'number' ? sleep(r) : null; })
|
|
137
|
+
.catch(function (e) { console.error('[overlaify] queue error', e); })
|
|
138
|
+
.then(function () { return sleep(gap); })
|
|
139
|
+
.then(next);
|
|
140
|
+
})();
|
|
141
|
+
}
|
|
142
|
+
return function (ev) { items.push(ev); pump(); };
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
escape: escapeHTML,
|
|
146
|
+
renderMessage: renderMessage,
|
|
147
|
+
sleep: sleep,
|
|
148
|
+
|
|
149
|
+
// Tiny built-in synth so overlays can have sound without audio files.
|
|
150
|
+
chime: function (kind, volume) {
|
|
151
|
+
if (params.get('mute') === '1') return;
|
|
152
|
+
try {
|
|
153
|
+
var C = O._ac || (O._ac = new (window.AudioContext || window.webkitAudioContext)());
|
|
154
|
+
var v = volume == null ? Number(params.get('volume') || 0.25) : volume;
|
|
155
|
+
var notes = { alert: [660, 880, 1320], pop: [880], coin: [988, 1319], fanfare: [523, 659, 784, 1047], low: [220, 165] }[kind || 'alert'] || [660];
|
|
156
|
+
var t = C.currentTime;
|
|
157
|
+
notes.forEach(function (f, i) {
|
|
158
|
+
var o = C.createOscillator(), g = C.createGain(), at = t + i * 0.1;
|
|
159
|
+
o.type = kind === 'coin' ? 'square' : (kind === 'low' ? 'sawtooth' : 'sine');
|
|
160
|
+
o.frequency.value = f;
|
|
161
|
+
g.gain.setValueAtTime(0.0001, at);
|
|
162
|
+
g.gain.exponentialRampToValueAtTime(Math.max(v, 0.0002), at + 0.015);
|
|
163
|
+
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.35);
|
|
164
|
+
o.connect(g); g.connect(C.destination);
|
|
165
|
+
o.start(at); o.stop(at + 0.4);
|
|
166
|
+
});
|
|
167
|
+
} catch (e) { /* audio not available */ }
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// Simulated events look like Twitch's: a simulated tip is bits, so it fires cheer too.
|
|
171
|
+
simulate: function (type, overrides) {
|
|
172
|
+
var d = Object.assign(fake(type), overrides || {});
|
|
173
|
+
if (type === 'cheer' || type === 'tip') {
|
|
174
|
+
if (type === 'tip' && overrides && overrides.amount != null) d.bits = Number(overrides.amount);
|
|
175
|
+
var fired = emitBits(O.emit, d);
|
|
176
|
+
return type === 'tip' ? fired.tip : fired.cheer;
|
|
177
|
+
}
|
|
178
|
+
return O.emit(type, d);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// fn(value, ev) runs when the action is dispatched; value is its input, if it takes one.
|
|
182
|
+
action: function (name, fn) {
|
|
183
|
+
return O.on('action:' + name, function (ev) { return fn(ev.value, ev); });
|
|
184
|
+
},
|
|
185
|
+
dispatch: function (name, value, extra) {
|
|
186
|
+
return O.emit('action:' + name, Object.assign({ value: value }, extra || {}));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------ */
|
|
191
|
+
/* Simulation data */
|
|
192
|
+
/* ------------------------------------------------------------------ */
|
|
193
|
+
var NAMES = ['PixelPanda', 'nightowl_gg', 'CaptainCrit', 'mochi_bytes', 'LagLord', 'SpeedySloth', 'xX_Quokka_Xx', 'RetroRaven', 'kindofabigdeal', 'ByteSizedBard', 'ZeroGravityZoe', 'TacoTuesday'];
|
|
194
|
+
var COLORS = ['#ff4f8b', '#c6ff3d', '#5ce1ff', '#ffb547', '#b48bff', '#ff6b4a', '#4dffa6', '#ffd23f'];
|
|
195
|
+
var LINES = ['LETS GOOO', 'that was insane 😂', 'gg', 'how long have you been playing this?', 'POG', 'chat is so cozy today', 'first time here, love the vibe!', 'no way he hit that', 'hydrate check 💧', 'one more run!', 'W streamer', 'what keyboard is that?'];
|
|
196
|
+
function pick(a) { return a[Math.floor(Math.random() * a.length)]; }
|
|
197
|
+
function rnd(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
|
|
198
|
+
function fake(type) {
|
|
199
|
+
var name = pick(NAMES);
|
|
200
|
+
var base = { platform: 'twitch', user: name.toLowerCase(), name: name, color: pick(COLORS), simulated: true };
|
|
201
|
+
switch (type) {
|
|
202
|
+
case 'chat': return Object.assign(base, { message: pick(LINES), emotes: null, badges: Math.random() < .3 ? ['subscriber'] : [], isMod: Math.random() < .1, isSub: Math.random() < .3, isVip: false, isBroadcaster: false, bits: 0 });
|
|
203
|
+
case 'sub': return Object.assign(base, { months: pick([1, 1, 3, 6, 12, 24]), tier: pick(['1', '1', '1', '2', '3', 'prime']), message: Math.random() < .5 ? pick(LINES) : '' });
|
|
204
|
+
case 'gift': var r = pick(NAMES); return Object.assign(base, { recipient: r.toLowerCase(), recipientName: r, tier: '1' });
|
|
205
|
+
case 'giftbomb': return Object.assign(base, { count: pick([5, 10, 20, 50]), tier: '1' });
|
|
206
|
+
case 'cheer': case 'tip': return Object.assign(base, { bits: pick([100, 250, 500, 1000, 5000]), message: pick(LINES), emotes: null });
|
|
207
|
+
case 'raid': return Object.assign(base, { viewers: rnd(8, 900) });
|
|
208
|
+
default: return base; // follow, custom
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ------------------------------------------------------------------ */
|
|
213
|
+
/* Connectors */
|
|
214
|
+
/* ------------------------------------------------------------------ */
|
|
215
|
+
// One per platform. start(source, hub) connects to source.channel and keeps reconnecting.
|
|
216
|
+
// hub.emit(type, data, opts) emit an event; `platform` is added for you
|
|
217
|
+
// hub.status(connected) report the connection going up or down
|
|
218
|
+
// Events must have the shapes in the spec, so overlays never need to know the platform.
|
|
219
|
+
var CONNECTORS = {
|
|
220
|
+
twitch: { start: startTwitch }
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function readSources() {
|
|
224
|
+
var list = Array.isArray(saved.sources) ? saved.sources : [];
|
|
225
|
+
var out = [];
|
|
226
|
+
list.forEach(function (s) {
|
|
227
|
+
if (!s || typeof s.platform !== 'string' || typeof s.channel !== 'string') return;
|
|
228
|
+
var src = { platform: s.platform.toLowerCase(), channel: cleanChannel(s.channel) };
|
|
229
|
+
if (src.channel) out.push(src);
|
|
230
|
+
});
|
|
231
|
+
if (O.channel && !out.some(function (s) { return s.platform === 'twitch' && s.channel === O.channel; })) {
|
|
232
|
+
out.unshift({ platform: 'twitch', channel: O.channel });
|
|
233
|
+
}
|
|
234
|
+
if (!O.channel) {
|
|
235
|
+
var tw = out.filter(function (s) { return s.platform === 'twitch'; })[0];
|
|
236
|
+
if (tw) O.channel = tw.channel;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function startSources() {
|
|
242
|
+
if (!('WebSocket' in window)) return;
|
|
243
|
+
O.sources.forEach(function (src) {
|
|
244
|
+
var c = CONNECTORS[src.platform];
|
|
245
|
+
if (!c) { console.warn('[overlaify] no connector for ' + src.platform); return; }
|
|
246
|
+
src.connected = false;
|
|
247
|
+
c.start(src, {
|
|
248
|
+
emit: function (type, data, opts) { return O.emit(type, Object.assign({ platform: src.platform }, data), opts); },
|
|
249
|
+
status: function (up) {
|
|
250
|
+
if (src.connected === up) return;
|
|
251
|
+
src.connected = up;
|
|
252
|
+
O.connected = O.sources.some(function (s) { return s.connected; });
|
|
253
|
+
O.emit(up ? 'connected' : 'disconnected', { platform: src.platform, channel: src.channel });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ------------------------------------------------------------------ */
|
|
260
|
+
/* Twitch: IRC chat (anonymous, read-only) */
|
|
261
|
+
/* ------------------------------------------------------------------ */
|
|
262
|
+
function unescapeTag(v) {
|
|
263
|
+
return v.replace(/\\(.)/g, function (_, c) { return ({ s: ' ', ':': ';', '\\': '\\', r: '\r', n: '\n' })[c] || c; });
|
|
264
|
+
}
|
|
265
|
+
function parseLine(line) {
|
|
266
|
+
var tags = {}, prefix = '', rest = line;
|
|
267
|
+
if (rest[0] === '@') {
|
|
268
|
+
var sp = rest.indexOf(' ');
|
|
269
|
+
rest.slice(1, sp).split(';').forEach(function (kv) {
|
|
270
|
+
var i = kv.indexOf('=');
|
|
271
|
+
if (i === -1) tags[kv] = ''; else tags[kv.slice(0, i)] = unescapeTag(kv.slice(i + 1));
|
|
272
|
+
});
|
|
273
|
+
rest = rest.slice(sp + 1);
|
|
274
|
+
}
|
|
275
|
+
if (rest[0] === ':') { var sp2 = rest.indexOf(' '); prefix = rest.slice(1, sp2); rest = rest.slice(sp2 + 1); }
|
|
276
|
+
var trailing = '', ti = rest.indexOf(' :');
|
|
277
|
+
if (ti !== -1) { trailing = rest.slice(ti + 2); rest = rest.slice(0, ti); }
|
|
278
|
+
var parts = rest.split(' ');
|
|
279
|
+
return { tags: tags, prefix: prefix, command: parts.shift(), params: parts, trailing: trailing };
|
|
280
|
+
}
|
|
281
|
+
function parseEmotes(s) {
|
|
282
|
+
if (!s) return null;
|
|
283
|
+
var out = {};
|
|
284
|
+
s.split('/').forEach(function (part) {
|
|
285
|
+
var c = part.indexOf(':'); if (c === -1) return;
|
|
286
|
+
out[part.slice(0, c)] = part.slice(c + 1).split(',').map(function (r) { var p = r.split('-'); return [Number(p[0]), Number(p[1])]; });
|
|
287
|
+
});
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
function tierOf(plan) { return plan === 'Prime' ? 'prime' : plan === '2000' ? '2' : plan === '3000' ? '3' : '1'; }
|
|
291
|
+
function badgesOf(s) { return s ? s.split(',').map(function (b) { return b.split('/')[0]; }) : []; }
|
|
292
|
+
|
|
293
|
+
function handleTwitch(m, ws, hub) {
|
|
294
|
+
var t = m.tags;
|
|
295
|
+
switch (m.command) {
|
|
296
|
+
case 'PING': ws.send('PONG :' + (m.trailing || 'tmi.twitch.tv')); break;
|
|
297
|
+
case 'RECONNECT': ws.close(); break;
|
|
298
|
+
case 'JOIN': hub.status(true); break;
|
|
299
|
+
case 'PRIVMSG': {
|
|
300
|
+
var text = m.trailing, action = false;
|
|
301
|
+
if (text.indexOf('\u0001ACTION ') === 0) { text = text.slice(8, -1); action = true; }
|
|
302
|
+
var login = m.prefix.split('!')[0];
|
|
303
|
+
var badges = badgesOf(t.badges);
|
|
304
|
+
var bits = Number(t.bits || 0);
|
|
305
|
+
var common = { user: login, userId: t['user-id'] || '', name: t['display-name'] || login, color: t.color || null };
|
|
306
|
+
hub.emit('chat', Object.assign({}, common, {
|
|
307
|
+
message: text, emotes: parseEmotes(t.emotes), badges: badges, action: action, bits: bits,
|
|
308
|
+
isMod: t.mod === '1' || badges.indexOf('moderator') !== -1, isSub: t.subscriber === '1',
|
|
309
|
+
isVip: badges.indexOf('vip') !== -1, isBroadcaster: badges.indexOf('broadcaster') !== -1
|
|
310
|
+
}));
|
|
311
|
+
if (bits > 0) emitBits(hub.emit, Object.assign({}, common, { bits: bits, message: text, emotes: parseEmotes(t.emotes) }));
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case 'USERNOTICE': {
|
|
315
|
+
var id = t['msg-id'];
|
|
316
|
+
var c = { user: t.login || '', userId: t['user-id'] || '', name: t['display-name'] || t.login || '', color: t.color || null };
|
|
317
|
+
if (id === 'sub' || id === 'resub') {
|
|
318
|
+
hub.emit('sub', Object.assign(c, { months: Number(t['msg-param-cumulative-months'] || 1), tier: tierOf(t['msg-param-sub-plan']), message: m.trailing || '', emotes: parseEmotes(t.emotes) }));
|
|
319
|
+
} else if (id === 'subgift' || id === 'anonsubgift') {
|
|
320
|
+
hub.emit('gift', Object.assign(c, { recipient: t['msg-param-recipient-user-name'] || '', recipientName: t['msg-param-recipient-display-name'] || '', tier: tierOf(t['msg-param-sub-plan']), anonymous: id === 'anonsubgift' }));
|
|
321
|
+
} else if (id === 'submysterygift' || id === 'anonsubmysterygift') {
|
|
322
|
+
hub.emit('giftbomb', Object.assign(c, { count: Number(t['msg-param-mass-gift-count'] || 1), tier: tierOf(t['msg-param-sub-plan']) }));
|
|
323
|
+
} else if (id === 'raid') {
|
|
324
|
+
hub.emit('raid', Object.assign(c, { user: t['msg-param-login'] || c.user, name: t['msg-param-displayName'] || c.name, viewers: Number(t['msg-param-viewerCount'] || 0) }));
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function startTwitch(src, hub) {
|
|
332
|
+
var backoff = 1000;
|
|
333
|
+
(function connect() {
|
|
334
|
+
var ws = new WebSocket('wss://irc-ws.chat.twitch.tv:443');
|
|
335
|
+
ws.onopen = function () {
|
|
336
|
+
backoff = 1000;
|
|
337
|
+
ws.send('CAP REQ :twitch.tv/tags twitch.tv/commands');
|
|
338
|
+
ws.send('PASS SCHMOOPIIE');
|
|
339
|
+
ws.send('NICK justinfan' + rnd(10000, 99999));
|
|
340
|
+
ws.send('JOIN #' + src.channel);
|
|
341
|
+
};
|
|
342
|
+
ws.onmessage = function (e) {
|
|
343
|
+
String(e.data).split('\r\n').forEach(function (line) {
|
|
344
|
+
if (!line) return;
|
|
345
|
+
try { handleTwitch(parseLine(line), ws, hub); } catch (err) { console.warn('[overlaify] parse error', err, line); }
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
ws.onclose = function () {
|
|
349
|
+
hub.status(false);
|
|
350
|
+
setTimeout(connect, backoff);
|
|
351
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
352
|
+
};
|
|
353
|
+
})();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ------------------------------------------------------------------ */
|
|
357
|
+
/* Live control: actions pressed on /c/:room */
|
|
358
|
+
/* ------------------------------------------------------------------ */
|
|
359
|
+
var liveBackoff = 1000;
|
|
360
|
+
function connectLive() {
|
|
361
|
+
var live = window.OVERLAIFY_LIVE;
|
|
362
|
+
if (!live || !live.room || inFrame || O.isTest || !('WebSocket' in window)) return;
|
|
363
|
+
var ws, ping;
|
|
364
|
+
try {
|
|
365
|
+
ws = new WebSocket((location.protocol === 'http:' ? 'ws:' : 'wss:') + '//' + location.host + '/api/live/' +
|
|
366
|
+
encodeURIComponent(live.room) + '?role=overlay' + (/\bOBS\//.test(navigator.userAgent) ? '&obs=1' : ''));
|
|
367
|
+
} catch (e) { return; }
|
|
368
|
+
ws.onopen = function () {
|
|
369
|
+
liveBackoff = 1000;
|
|
370
|
+
ping = setInterval(function () { ws.send('ping'); }, 30000);
|
|
371
|
+
};
|
|
372
|
+
ws.onmessage = function (e) {
|
|
373
|
+
var m;
|
|
374
|
+
try { m = JSON.parse(e.data); } catch (err) { return; }
|
|
375
|
+
if (!m || m.t !== 'action' || typeof m.name !== 'string') return;
|
|
376
|
+
O.dispatch(m.name, m.value, { live: true });
|
|
377
|
+
ws.send(JSON.stringify({ t: 'acted', id: m.id, name: m.name, handled: (listeners['action:' + m.name] || []).length > 0 }));
|
|
378
|
+
};
|
|
379
|
+
ws.onclose = function () {
|
|
380
|
+
clearInterval(ping);
|
|
381
|
+
setTimeout(connectLive, liveBackoff);
|
|
382
|
+
liveBackoff = Math.min(liveBackoff * 2, 30000);
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ------------------------------------------------------------------ */
|
|
387
|
+
/* Editor bridge + test mode */
|
|
388
|
+
/* ------------------------------------------------------------------ */
|
|
389
|
+
window.addEventListener('message', function (e) {
|
|
390
|
+
var d = e.data;
|
|
391
|
+
if (d && d.__overlaify === 'action' && typeof d.name === 'string') {
|
|
392
|
+
O.dispatch(d.name, d.value);
|
|
393
|
+
if (e.source) e.source.postMessage({ __overlaify: 'acted', name: d.name, handled: (listeners['action:' + d.name] || []).length > 0 }, '*');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!d || d.__overlaify !== 'simulate' || typeof d.type !== 'string') return;
|
|
397
|
+
O.simulate(d.type, d.data);
|
|
398
|
+
// Tell the editor whether anything listened, so it can flag events this overlay ignores.
|
|
399
|
+
var types = d.type === 'cheer' || d.type === 'tip' ? ['cheer', 'tip', '*'] : [d.type, '*'];
|
|
400
|
+
var handled = types.some(function (t) { return (listeners[t] || []).length > 0; });
|
|
401
|
+
if (e.source) e.source.postMessage({ __overlaify: 'simulated', type: d.type, handled: handled }, '*');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
function startTestMode() {
|
|
405
|
+
var weights = [['chat', 10], ['follow', 2], ['sub', 2], ['cheer', 2], ['gift', 1], ['giftbomb', .5], ['raid', .5]];
|
|
406
|
+
var total = weights.reduce(function (s, w) { return s + w[1]; }, 0);
|
|
407
|
+
(function tick() {
|
|
408
|
+
var r = Math.random() * total, type = 'chat';
|
|
409
|
+
for (var i = 0; i < weights.length; i++) { r -= weights[i][1]; if (r <= 0) { type = weights[i][0]; break; } }
|
|
410
|
+
O.simulate(type);
|
|
411
|
+
setTimeout(tick, type === 'chat' ? rnd(900, 2200) : rnd(2500, 4500));
|
|
412
|
+
})();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function hint() {
|
|
416
|
+
if (O.sources.length || O.isTest || inFrame) return;
|
|
417
|
+
var el = document.createElement('div');
|
|
418
|
+
el.textContent = 'Overlaify: add ?channel=YOUR_TWITCH_NAME to this URL';
|
|
419
|
+
el.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483647;font:600 16px system-ui,sans-serif;color:#0b0a10;background:#c6ff3d;padding:10px 14px;border-radius:8px';
|
|
420
|
+
document.body.appendChild(el);
|
|
421
|
+
setTimeout(function () { el.remove(); }, 15000);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
O.sources = readSources();
|
|
425
|
+
window.Overlaify = O;
|
|
426
|
+
|
|
427
|
+
function boot() {
|
|
428
|
+
// Let overlay code register listeners first.
|
|
429
|
+
setTimeout(function () {
|
|
430
|
+
startSources();
|
|
431
|
+
connectLive();
|
|
432
|
+
if (O.isTest) startTestMode();
|
|
433
|
+
if (document.body) hint();
|
|
434
|
+
}, 50);
|
|
435
|
+
}
|
|
436
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
437
|
+
})();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<title>Cozy BRB Screen</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,800&family=DM+Sans:wght@400;600&display=swap" rel="stylesheet">
|
|
9
|
+
<script type="application/json" id="overlaify-manifest">
|
|
10
|
+
{
|
|
11
|
+
"spec": 1,
|
|
12
|
+
"settings": [
|
|
13
|
+
{"key": "tag", "label": "Small heading", "type": "text", "default": "Quick break", "group": "Text"},
|
|
14
|
+
{"key": "title", "label": "Headline", "type": "text", "default": "Be right back", "group": "Text"},
|
|
15
|
+
{"key": "text", "label": "Line under the headline", "type": "text", "default": "Stretching, refilling, back in a few. Keep chat warm for me.", "group": "Text"},
|
|
16
|
+
{"key": "minutes", "label": "Back in", "type": "range", "default": 0, "min": 0, "max": 60, "step": 1, "unit": "min", "desc": "Shows a countdown. 0 counts how long you've been away instead.", "group": "Timing"},
|
|
17
|
+
{"key": "color", "label": "Accent color", "type": "color", "default": "#ffb547", "swatches": ["#ffb547", "#ff8a5c", "#ff4f8b", "#c6ff3d", "#5ce1ff"], "group": "Look"},
|
|
18
|
+
{"key": "bg", "label": "Background", "type": "toggle", "default": true, "desc": "Turn off for a transparent background over your own art.", "group": "Look"}
|
|
19
|
+
],
|
|
20
|
+
"actions": [
|
|
21
|
+
{"name": "backin", "label": "Back in…", "input": {"type": "number", "label": "Minutes", "default": 5, "step": 1, "min": 1}},
|
|
22
|
+
{"name": "restart", "label": "Restart timer", "style": "secondary"}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
<style>
|
|
27
|
+
:root { --c: #ffb547; --ink: #2a1a12; }
|
|
28
|
+
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
|
|
29
|
+
.scene { position: absolute; inset: 0; overflow: hidden; color: #fff4e6; font-family: 'DM Sans', system-ui, sans-serif;
|
|
30
|
+
background: radial-gradient(90% 80% at 70% 30%, #4a2a1c 0%, #22140f 55%, #120b08 100%); }
|
|
31
|
+
.clear .scene { background: transparent; }
|
|
32
|
+
.clear .glow, .clear .dust { display: none; }
|
|
33
|
+
.glow { position: absolute; width: 900px; height: 900px; right: 160px; top: 40px; border-radius: 50%; background: radial-gradient(circle, rgba(255,181,71,.35), transparent 65%); animation: breathe 6s ease-in-out infinite; }
|
|
34
|
+
.dust i { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: rgba(255,220,170,.5); animation: float linear infinite; }
|
|
35
|
+
.left { position: absolute; left: 150px; top: 280px; width: 980px; }
|
|
36
|
+
.tag { font-size: 24px; font-weight: 600; letter-spacing: .28em; text-transform: uppercase; color: var(--c); }
|
|
37
|
+
h1 { margin: 18px 0 0; font: 800 160px/0.92 'Fraunces', Georgia, serif; letter-spacing: -.035em; }
|
|
38
|
+
.text { margin-top: 30px; font-size: 34px; color: #f2dcc4; max-width: 860px; }
|
|
39
|
+
.timer { margin-top: 38px; display: inline-flex; align-items: baseline; gap: 16px; padding: 16px 28px; border-radius: 99px; background: rgba(255,181,71,.12); border: 2px solid rgba(255,181,71,.4); font-size: 26px; color: #f2dcc4; }
|
|
40
|
+
.timer b { font: 600 46px 'Fraunces', Georgia, serif; color: var(--c); font-variant-numeric: tabular-nums; }
|
|
41
|
+
.mug { position: absolute; right: 330px; top: 300px; width: 300px; height: 300px; }
|
|
42
|
+
.cup { position: absolute; left: 30px; bottom: 0; width: 200px; height: 190px; border-radius: 0 0 60px 60px; background: linear-gradient(180deg, var(--c), #e0892a); box-shadow: inset -18px 0 0 rgba(0,0,0,.12), 0 30px 60px rgba(0,0,0,.45); }
|
|
43
|
+
.cup::after { content: ''; position: absolute; right: -62px; top: 34px; width: 70px; height: 90px; border: 20px solid #e8972f; border-left: 0; border-radius: 0 50px 50px 0; }
|
|
44
|
+
.cup::before { content: ''; position: absolute; left: 0; right: 0; top: -14px; height: 28px; border-radius: 50%; background: #5a3220; border: 8px solid #f0a646; box-sizing: border-box; }
|
|
45
|
+
.steam { position: absolute; bottom: 210px; width: 22px; height: 110px; border-radius: 50%; border-left: 8px solid rgba(255,244,230,.55); animation: steam 3s ease-in-out infinite; }
|
|
46
|
+
.s1 { left: 70px; } .s2 { left: 120px; animation-delay: -1s; } .s3 { left: 170px; animation-delay: -2s; }
|
|
47
|
+
.ticker { position: absolute; left: 0; right: 0; bottom: 70px; height: 72px; display: flex; align-items: center; gap: 18px; padding: 0 150px; overflow: hidden; }
|
|
48
|
+
.ticker .lbl { flex: none; font-size: 18px; font-weight: 600; letter-spacing: .24em; text-transform: uppercase; color: rgba(255,244,230,.5); }
|
|
49
|
+
.ticker .line { display: flex; gap: 14px; overflow: hidden; mask-image: linear-gradient(90deg, #000 85%, transparent); }
|
|
50
|
+
.m { flex: none; font-size: 24px; padding: 10px 18px; border-radius: 12px; background: rgba(255,244,230,.08); border: 1px solid rgba(255,244,230,.12); animation: pop .4s cubic-bezier(.2,1.4,.4,1); white-space: nowrap; max-width: 620px; overflow: hidden; text-overflow: ellipsis; }
|
|
51
|
+
.m b { color: var(--uc, var(--c)); margin-right: 8px; }
|
|
52
|
+
.m.hype { background: rgba(255,181,71,.2); border-color: var(--c); }
|
|
53
|
+
@keyframes breathe { 50% { transform: scale(1.08); opacity: .8; } }
|
|
54
|
+
@keyframes steam { 0% { transform: translateY(20px) scaleX(1); opacity: 0; } 40% { opacity: 1; } 100% { transform: translateY(-50px) scaleX(-1); opacity: 0; } }
|
|
55
|
+
@keyframes float { from { transform: translateY(0); opacity: 0; } 20% { opacity: 1; } to { transform: translateY(-600px); opacity: 0; } }
|
|
56
|
+
@keyframes pop { from { opacity: 0; transform: translateY(16px) scale(.95); } }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="scene">
|
|
61
|
+
<div class="glow"></div><div class="dust" id="dust"></div>
|
|
62
|
+
<div class="left">
|
|
63
|
+
<div class="tag" id="tag"></div>
|
|
64
|
+
<h1 id="title"></h1>
|
|
65
|
+
<div class="text" id="text"></div>
|
|
66
|
+
<div class="timer"><span id="tlabel"></span><b id="time">00:00</b></div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="mug"><div class="steam s1"></div><div class="steam s2"></div><div class="steam s3"></div><div class="cup"></div></div>
|
|
69
|
+
<div class="ticker"><span class="lbl">Chat</span><div class="line" id="line"></div></div>
|
|
70
|
+
</div>
|
|
71
|
+
<script>
|
|
72
|
+
// ?minutes=10 counts down; without it the timer counts how long you've been away.
|
|
73
|
+
// &title=Be%20right%20back &text=... &color=%23ffb547 &bg=0 (transparent)
|
|
74
|
+
const esc = Overlaify.escape;
|
|
75
|
+
document.documentElement.style.setProperty('--c', Overlaify.param('color', '#ffb547'));
|
|
76
|
+
if (Overlaify.param('bg', true) === false) document.body.classList.add('clear');
|
|
77
|
+
document.getElementById('tag').textContent = Overlaify.param('tag', 'Quick break');
|
|
78
|
+
document.getElementById('title').textContent = Overlaify.param('title', 'Be right back');
|
|
79
|
+
document.getElementById('text').textContent = Overlaify.param('text', 'Stretching, refilling, back in a few. Keep chat warm for me.');
|
|
80
|
+
|
|
81
|
+
const dust = document.getElementById('dust');
|
|
82
|
+
for (let i = 0; i < 24; i++) {
|
|
83
|
+
const d = document.createElement('i');
|
|
84
|
+
d.style.cssText = `left:${Math.random() * 100}%;top:${60 + Math.random() * 50}%;animation-duration:${8 + Math.random() * 10}s;animation-delay:${-Math.random() * 18}s`;
|
|
85
|
+
dust.appendChild(d);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MIN = Overlaify.param('minutes', 0);
|
|
89
|
+
// countdown: true counts down to end, false counts up from start.
|
|
90
|
+
let countdown, start, end;
|
|
91
|
+
function startTimer(min) {
|
|
92
|
+
countdown = min > 0; start = Date.now(); end = start + min * 60e3;
|
|
93
|
+
document.getElementById('tlabel').textContent = countdown ? 'Back in' : 'Away for';
|
|
94
|
+
}
|
|
95
|
+
startTimer(MIN);
|
|
96
|
+
Overlaify.action('backin', min => { if (Number(min) > 0) startTimer(Number(min)); });
|
|
97
|
+
Overlaify.action('restart', () => startTimer(MIN));
|
|
98
|
+
const time = document.getElementById('time');
|
|
99
|
+
const pad = n => String(n).padStart(2, '0');
|
|
100
|
+
(function tick() {
|
|
101
|
+
const s = Math.max(0, Math.round((countdown ? end - Date.now() : Date.now() - start) / 1000));
|
|
102
|
+
time.textContent = (s >= 3600 ? Math.floor(s / 3600) + ':' : '') + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60);
|
|
103
|
+
setTimeout(tick, 250);
|
|
104
|
+
})();
|
|
105
|
+
|
|
106
|
+
const line = document.getElementById('line');
|
|
107
|
+
function push(html, hype, color) {
|
|
108
|
+
const el = document.createElement('div');
|
|
109
|
+
el.className = 'm' + (hype ? ' hype' : '');
|
|
110
|
+
if (color) el.style.setProperty('--uc', color);
|
|
111
|
+
el.innerHTML = html;
|
|
112
|
+
line.prepend(el);
|
|
113
|
+
while (line.children.length > 6) line.lastElementChild.remove();
|
|
114
|
+
}
|
|
115
|
+
Overlaify.on('chat', e => { if (!e.message.startsWith('!')) push(`<b>${esc(e.name)}</b>${Overlaify.renderMessage(e.message, e.emotes)}`, false, e.color); });
|
|
116
|
+
Overlaify.on('sub', e => push(`<b>${esc(e.name)}</b>${e.months > 1 ? `resubbed · ${e.months} months` : 'subscribed'} ☕`, true));
|
|
117
|
+
Overlaify.on('gift', e => push(`<b>${esc(e.name)}</b>gifted a sub to ${esc(e.recipientName || e.recipient)}`, true));
|
|
118
|
+
Overlaify.on('giftbomb', e => push(`<b>${esc(e.name)}</b>gifted ${e.count} subs`, true));
|
|
119
|
+
Overlaify.on('cheer', e => push(`<b>${esc(e.name)}</b>cheered ${e.bits.toLocaleString()} bits`, true));
|
|
120
|
+
Overlaify.on('raid', e => { push(`<b>${esc(e.name)}</b>raided with ${e.viewers.toLocaleString()}`, true); Overlaify.chime('alert'); });
|
|
121
|
+
</script>
|
|
122
|
+
</body>
|
|
123
|
+
</html>
|