openvibers/openvibe.live Commits
An alerts overlay in your look: follows, tips, subs, raids and goals; demo=1 to line up OBS (#489)
Changes
nnew file mode 100644@@@ -0,0 +1,164 @@++<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="UTF-8">++<meta name="viewport" content="width=device-width, initial-scale=1.0">++<meta name="robots" content="noindex">++<title>Alerts</title>++<!-- An OBS browser source (plan L45 step 5): the moments of a stream, in the streamer's look.++ /obs/alerts/<username>++ Follows, tips, subs, raids and goals arrive over the channel's chat room as `overlay-alert` messages; each one++ springs in, says who and what, glints, and leaves after a few seconds; a big tip or a goal throws confetti.++ Query: theme=<theme id> (default Vibe), pos=top|bottom (default top), demo=1 (sample alerts, to line up OBS).++ The ground is transparent; size the source 1920 x 1080. -->++<style>++@font-face { font-family: 'OV Brand'; src: url('/shared/ov-brand.woff2') format('woff2'); font-weight: 700 900; font-display: swap; }++*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }++html, body { width: 100%; height: 100%; overflow: hidden; background: transparent !important; }++body { --o: #4f7cff; --v: #8b5cf6; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--text-primary, #e6edf7); }++.al-stage { position: fixed; left: 0; right: 0; top: 3vw; display: grid; justify-items: center; pointer-events: none; }++body[data-pos=bottom] .al-stage { top: auto; bottom: 3vw; }++.al { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2vw; align-items: center; min-width: 26vw; max-width: 46vw;++ padding: 1.1vw 1.8vw 1.1vw 1.1vw; border-radius: 1.6vw; overflow: hidden; isolation: isolate;++ background: color-mix(in srgb, var(--bg-card, #111827) 88%, transparent); border: 1px solid color-mix(in srgb, var(--accent, var(--o)) 40%, transparent);++ box-shadow: 0 1.4vw 3.6vw -1vw rgba(0,0,0,.55), 0 0 0 .3vw color-mix(in srgb, var(--accent, var(--o)) 14%, transparent);++ animation: alIn .7s cubic-bezier(.34,1.56,.64,1) both; }++.al.is-out { animation: alOut .45s cubic-bezier(.65,0,.35,1) both; }++@keyframes alIn { from { opacity: 0; transform: translateY(-2.4vw) scale(.92); } }++@keyframes alOut { to { opacity: 0; transform: translateY(-1.2vw) scale(.96); } }++body[data-pos=bottom] .al { animation-name: alInUp; }++@keyframes alInUp { from { opacity: 0; transform: translateY(2.4vw) scale(.92); } }++/* The glint, as the mark's: a band of light crosses once, just after it lands. */++.al::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(105deg, transparent 35%, rgba(255,255,255,.22) 50%, transparent 65%); transform: translateX(-120%); animation: alGlint 1s .55s cubic-bezier(.45,0,.25,1) forwards; }++@keyframes alGlint { to { transform: translateX(120%); } }++.al-ic { position: relative; display: grid; place-items: center; width: 4.6vw; height: 4.6vw; border-radius: 1.2vw; font-size: 2.2vw;++ background: linear-gradient(135deg, color-mix(in srgb, var(--accent, var(--o)) 85%, #fff 15%), var(--v)); color: #fff; }++.al-ic img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }++.al-ic b { position: absolute; right: -.5vw; bottom: -.5vw; display: grid; place-items: center; width: 2vw; height: 2vw; border-radius: 50%; font-size: 1vw; background: var(--accent-strong, var(--accent, var(--o))); color: #fff; border: .2vw solid var(--bg-card, #111827); }++.al-t { display: grid; gap: .25vw; min-width: 0; }++.al-who { font-family: 'OV Brand', Nunito, Inter, sans-serif; font-weight: 900; font-size: 2.1vw; line-height: 1.1; letter-spacing: -.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.al-what { font-size: 1.35vw; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++.al-what em { font-style: normal; color: var(--accent-light, var(--accent, var(--o))); font-weight: 800; }++.al-msg { margin-top: .3vw; font-size: 1.3vw; line-height: 1.35; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.al-msg:empty { display: none; }++.al-status { position: fixed; left: 1vw; bottom: 1vw; font-size: .9vw; color: rgba(255,255,255,.55); text-shadow: 0 1px 2px #000; }++.al-status:empty { display: none; }++@media (prefers-reduced-motion: reduce) { .al, .al.is-out { animation: none; } .al::after { display: none; } }++</style>++<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">++<script src="/shared/theme-loader.js"></script>++</head>++<body>++<div class="al-stage" id="al-stage" aria-live="polite"></div>++<p class="al-status" id="al-status"></p>++<script src="/shared/ov-fx.js"></script>++<script>++(function () {++ 'use strict';++ var parts = location.pathname.split('/').filter(Boolean); // obs, alerts, <username>++ var user = /^[A-Za-z0-9_.-]{1,40}$/.test(parts[2] || '') ? parts[2] : '';++ var q = new URLSearchParams(location.search);++ var $ = function (id) { return document.getElementById(id); };++ var L = window.OpenVibeThemeLoader;++ var themeId = /^[a-z0-9-]{1,40}$/.test(q.get('theme') || '') ? q.get('theme') : 'vibe';++ if (L && L.THEMES && L.THEMES[themeId]) { L.applyVars(L.THEMES[themeId]); document.documentElement.setAttribute('data-theme', themeId); }++ document.documentElement.removeAttribute('data-ov-season'); document.documentElement.removeAttribute('data-ov-season-mark');++ document.body.setAttribute('data-pos', q.get('pos') === 'bottom' ? 'bottom' : 'top');++ var status = function (t) { $('al-status').textContent = t || ''; };++++ // ── One alert at a time, each for a few seconds; seen ids are never shown twice ──++ var KIND = {++ follow: { icon: 'fa-heart', say: 'followed' },++ tip: { icon: 'fa-coins', say: 'tipped' },++ sub: { icon: 'fa-star', say: 'subscribed' },++ raid: { icon: 'fa-users', say: 'raided' },++ goal: { icon: 'fa-trophy', say: 'Goal reached' },++ };++ var queue = [], showing = false, seen = new Set();++ function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }++ function icon(name) { var i = document.createElement('i'); i.className = 'fa-solid ' + name; i.setAttribute('aria-hidden', 'true'); return i; }++ function amountText(a) {++ if (!a || !(Number(a.value) > 0)) return '';++ var unit = String(a.unit || '').slice(0, 16);++ return Number(a.value).toLocaleString() + (unit ? ' ' + unit : '');++ }++ function card(a) {++ var k = KIND[a.kind];++ var ic = el('div', 'al-ic');++ var who = a.who && typeof a.who === 'object' ? a.who : null;++ if (who && /^https:\/\//.test(who.avatar || '')) { var img = document.createElement('img'); img.alt = ''; img.onerror = function () { img.replaceWith(icon(k.icon)); }; img.src = who.avatar; ic.append(img); var badge = el('b'); badge.append(icon(k.icon)); ic.append(badge); }++ else ic.append(icon(k.icon));++ var name = a.kind === 'goal' ? 'Goal reached' : (who && who.name ? String(who.name).slice(0, 40) : 'Someone');++ var what = el('p', 'al-what');++ var amt = amountText(a.amount);++ if (a.kind === 'goal') what.append(a.text ? String(a.text).slice(0, 80) : 'Thank you, everyone');++ else if (a.kind === 'raid') { what.append('raided'); if (amt) { what.append(' with '); var e = el('em'); e.textContent = amt; what.append(e); } }++ else { what.append(k.say); if (amt) { what.append(' '); var e2 = el('em'); e2.textContent = amt; what.append(e2); } }++ var msg = el('p', 'al-msg');++ if (a.kind === 'tip' && a.text) msg.textContent = String(a.text).slice(0, 200);++ var t = el('div', 'al-t', [el('p', 'al-who', [name]), what, msg]);++ return el('div', 'al', [ic, t]);++ }++ function next() {++ if (showing || !queue.length) return;++ showing = true;++ var a = queue.shift(), node = card(a), stage = $('al-stage');++ stage.replaceChildren(node);++ var big = a.kind === 'goal' || a.kind === 'raid' || (a.kind === 'tip' && Number(a.amount && a.amount.value) >= 10) || a.kind === 'sub';++ if (big && window.OVFx && window.OVFx.confetti) setTimeout(function () { var r = node.getBoundingClientRect(); window.OVFx.confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: a.kind === 'goal' ? 140 : 90 }); }, 450);++ var stay = a.kind === 'tip' && a.text ? 8000 : 5500;++ setTimeout(function () { node.classList.add('is-out'); setTimeout(function () { node.remove(); showing = false; next(); }, 460); }, stay);++ }++ function take(a) {++ if (!a || !KIND[a.kind]) return;++ var id = String(a.id || '');++ if (id) { if (seen.has(id)) return; seen.add(id); if (seen.size > 500) seen.delete(seen.values().next().value); }++ queue.push(a); if (queue.length > 20) queue.shift();++ next();++ }++ window.OVAlerts = { take: take };++++ // ── Demo: a sample of each, to line up OBS ──++ if (q.get('demo') === '1') {++ var samples = [++ { kind: 'follow', id: 'd1', who: { name: 'Wren' } },++ { kind: 'tip', id: 'd2', who: { name: 'Maticus' }, amount: { value: 25, unit: 'Vibes' }, text: 'For the robot fund. Make it dance!' },++ { kind: 'sub', id: 'd3', who: { name: 'JapaneseOldGuy' } },++ { kind: 'raid', id: 'd4', who: { name: 'Goosely' }, amount: { value: 42, unit: 'viewers' } },++ { kind: 'goal', id: 'd5', text: 'New camera: 100%' },++ ];++ var i = 0;++ var loop = function () { var s = samples[i % samples.length]; take(Object.assign({}, s, { id: s.id + '-' + i })); i++; setTimeout(loop, 7000); };++ loop();++ status('Demo alerts');++ return;++ }++++ // ── The channel's room: overlay-alert messages, channel-wide, live or not ──++ if (!user) { status('No channel in the address'); return; }++ var ws = null, delay = 2000, streamId = null;++ function resolve() {++ return fetch('/api/streams/channel/' + encodeURIComponent(user)).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d || !d.channel) { status('Channel not found'); return false; }++ var s = d.stream && d.stream.id ? d.stream : ((d.streams || [])[0] || null);++ streamId = s && s.id ? s.id : null;++ return true;++ }).catch(function () { return false; });++ }++ function connect() {++ var proto = location.protocol === 'https:' ? 'wss' : 'ws';++ ws = new WebSocket(proto + '://' + location.host + '/ws/chat' + (streamId ? '?stream=' + streamId : ''));++ ws.onopen = function () { delay = 2000; status(''); ws.send(JSON.stringify({ type: 'join', streamId: streamId, channel: user })); };++ ws.onmessage = function (e) { try { var m = JSON.parse(e.data); if (m && m.type === 'overlay-alert') take(m); } catch (err) { /* not ours */ } };++ ws.onclose = function () { status('Reconnecting…'); setTimeout(function () { resolve().then(connect); }, delay); delay = Math.min(delay * 1.6, 30000); };++ }++ resolve().then(function (ok) { if (ok) connect(); else setTimeout(function () { location.reload(); }, 30000); });++ // The stream changes when it goes live or ends: join again then.++ setInterval(function () {++ var before = streamId;++ resolve().then(function () { if (streamId !== before && ws && ws.readyState === 1) ws.close(); });++ }, 60000);++})();++</script>++</body>++</html>@@@ -1050,6 +1050,11 @@ app.get(/^\/obs\/scene\/(starting|brb|ending)\/[A-Za-z0-9_.-]{1,40}\/?$/, (req,sendDocument(res, 'obs/scene.html');});++// The moments of a stream in the streamer's look (plan L45): follows, tips, subs, raids and goals as they happen.++app.get(/^\/obs\/alerts\/[A-Za-z0-9_.-]{1,40}\/?$/, (req, res) => {++ sendDocument(res, 'obs/alerts.html');++});++// New overlay routes — per-slot and globalapp.get('/overlay/chat/:username/:slotIdOrSlug', (req, res) => {sendDocument(res, 'obs/chat.html');nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++// The alerts overlay (plan L45 step 5): /obs/alerts/<username> shows a channel's moments (follow, tip, sub, raid, goal)++// from `overlay-alert` messages on its chat room, one at a time, in the look given by name. Every value from a message++// is text (a tipper's words can never be markup), ids are shown once, the queue is bounded, and demo=1 plays samples.++// The page runs here on a tiny DOM.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/obs/alerts.html'), 'utf8');++const index = fs.readFileSync(path.join(__dirname, '..', 'server/index.js'), 'utf8');++const script = SRC.slice(SRC.lastIndexOf('<script>') + 8, SRC.lastIndexOf('</script>'));++++// ── The route ──++const re = /^\/obs\/alerts\/[A-Za-z0-9_.-]{1,40}\/?$/;++assert.ok(index.includes(`app.get(${re.toString()}`), 'the alerts route');++assert.ok(re.test('/obs/alerts/goosely') && !re.test('/obs/alerts/<x>') && !re.test('/obs/alerts/'), 'a username only');++++// ── The page, statically ──++assert.ok(!/innerHTML/.test(script), 'a message is only ever text');++assert.match(SRC, /background: transparent !important/, 'a transparent ground over the stream');++assert.match(SRC, /@media \(prefers-reduced-motion: reduce\)/);++assert.match(script, /m\.type === 'overlay-alert'/, 'only overlay-alert messages');++assert.match(script, /type: 'join', streamId: streamId, channel: user/, 'joins the channel\'s room, live or not');++++// ── The page, running on a tiny DOM ──++class N {++ constructor(t) { this.tagName = t; this.children = []; this.attrs = {}; this.className = ''; this._text = ''; this.style = {}; }++ append(...k) { k.forEach((c) => this.children.push(typeof c === 'string' ? Object.assign(new N('#t'), { _text: c }) : c)); }++ replaceChildren(...k) { this.children = []; this.append(...k); }++ replaceWith() {} remove() { this.removed = true; }++ setAttribute(k, v) { this.attrs[k] = String(v); } removeAttribute() {}++ get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }++ set textContent(v) { this._text = String(v); this.children = []; }++ getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 40 }; }++}++function run(href) {++ const ids = { 'al-stage': new N('div'), 'al-status': new N('p') };++ const timers = [];++ const sockets = [];++ const confetti = [];++ const u = new URL(href);++ const sandbox = {++ location: { pathname: u.pathname, search: u.search, protocol: 'https:', host: 'openvibe.live', reload() {} },++ URLSearchParams, Set, Number, String, Object, JSON, Math, Date,++ document: { getElementById: (id) => ids[id], createElement: (t) => new N(t), documentElement: new N('html'), body: new N('body') },++ setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },++ setInterval: () => 0,++ fetch: async () => ({ ok: true, json: async () => ({ channel: { username: 'goosely' }, stream: { id: 7 } }) }),++ WebSocket: class { constructor(url) { this.url = url; this.sent = []; sockets.push(this); } send(m) { this.sent.push(JSON.parse(m)); } close() {} },++ };++ sandbox.window = sandbox;++ sandbox.OVFx = { confetti: (o) => confetti.push(o) };++ vm.runInNewContext(script, sandbox);++ const runTimers = (max = 50) => { for (let i = 0; i < max && timers.length; i++) timers.shift().fn(); };++ return { ids, sandbox, sockets, confetti, runTimers, timers };++}++++(async () => {++ const p = run('https://openvibe.live/obs/alerts/goosely?theme=paper');++ await new Promise((r) => setImmediate(r));++ await new Promise((r) => setImmediate(r));++ const ws = p.sockets[0];++ assert.ok(ws && ws.url === 'wss://openvibe.live/ws/chat?stream=7', 'the channel\'s chat room');++ ws.onopen();++ assert.deepStrictEqual(ws.sent[0], { type: 'join', streamId: 7, channel: 'goosely' });++++ // A tip with words that try to be markup: shown as text.++ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'tip', id: 't1', who: { name: 'Wren', avatar: 'javascript:alert(1)' }, amount: { value: 25, unit: 'Vibes' }, text: '<img src=x onerror=alert(1)>' }) });++ const stage = p.ids['al-stage'];++ assert.match(stage.textContent, /^Wren/);++ assert.match(stage.textContent, /tipped 25 Vibes/);++ assert.ok(stage.textContent.includes('<img src=x onerror=alert(1)>'), 'the words, as words');++ const imgs = []; const walk = (n) => { if (n.tagName === 'img') imgs.push(n); n.children.forEach(walk); }; walk(stage);++ assert.strictEqual(imgs.length, 0, 'only an https avatar is shown');++++ // The same id twice: shown once. Other messages: ignored.++ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'tip', id: 't1', who: { name: 'Wren' } }) });++ ws.onmessage({ data: JSON.stringify({ type: 'chat', message: 'hi' }) });++ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'nonsense', id: 'x' }) });++ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'follow', id: 'f1', who: { name: 'Goosely' } }) });++ p.runTimers();++ assert.match(stage.textContent, /Goosely.*followed/, 'next in line: the follow, once the tip has had its time');++ assert.ok(p.confetti.length >= 1, 'a tip of 10 or more throws confetti');++++ // Demo mode plays samples without a channel or a socket.++ const d = run('https://openvibe.live/obs/alerts/goosely?demo=1');++ assert.strictEqual(d.sockets.length, 0);++ assert.match(d.ids['al-stage'].textContent, /Wren.*followed/);++ console.log('obs alerts: the channel room, text only, once per id, a queue, confetti for the big ones, a demo');++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="robots" content="noindex">
+<title>Alerts</title>
+<!-- An OBS browser source (plan L45 step 5): the moments of a stream, in the streamer's look.
+ /obs/alerts/<username>
+ Follows, tips, subs, raids and goals arrive over the channel's chat room as `overlay-alert` messages; each one
+ springs in, says who and what, glints, and leaves after a few seconds; a big tip or a goal throws confetti.
+ Query: theme=<theme id> (default Vibe), pos=top|bottom (default top), demo=1 (sample alerts, to line up OBS).
+ The ground is transparent; size the source 1920 x 1080. -->
+<style>
+@font-face { font-family: 'OV Brand'; src: url('/shared/ov-brand.woff2') format('woff2'); font-weight: 700 900; font-display: swap; }
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html, body { width: 100%; height: 100%; overflow: hidden; background: transparent !important; }
+body { --o: #4f7cff; --v: #8b5cf6; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--text-primary, #e6edf7); }
+.al-stage { position: fixed; left: 0; right: 0; top: 3vw; display: grid; justify-items: center; pointer-events: none; }
+body[data-pos=bottom] .al-stage { top: auto; bottom: 3vw; }
+.al { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2vw; align-items: center; min-width: 26vw; max-width: 46vw;
+ padding: 1.1vw 1.8vw 1.1vw 1.1vw; border-radius: 1.6vw; overflow: hidden; isolation: isolate;
+ background: color-mix(in srgb, var(--bg-card, #111827) 88%, transparent); border: 1px solid color-mix(in srgb, var(--accent, var(--o)) 40%, transparent);
+ box-shadow: 0 1.4vw 3.6vw -1vw rgba(0,0,0,.55), 0 0 0 .3vw color-mix(in srgb, var(--accent, var(--o)) 14%, transparent);
+ animation: alIn .7s cubic-bezier(.34,1.56,.64,1) both; }
+.al.is-out { animation: alOut .45s cubic-bezier(.65,0,.35,1) both; }
+@keyframes alIn { from { opacity: 0; transform: translateY(-2.4vw) scale(.92); } }
+@keyframes alOut { to { opacity: 0; transform: translateY(-1.2vw) scale(.96); } }
+body[data-pos=bottom] .al { animation-name: alInUp; }
+@keyframes alInUp { from { opacity: 0; transform: translateY(2.4vw) scale(.92); } }
+/* The glint, as the mark's: a band of light crosses once, just after it lands. */
+.al::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(105deg, transparent 35%, rgba(255,255,255,.22) 50%, transparent 65%); transform: translateX(-120%); animation: alGlint 1s .55s cubic-bezier(.45,0,.25,1) forwards; }
+@keyframes alGlint { to { transform: translateX(120%); } }
+.al-ic { position: relative; display: grid; place-items: center; width: 4.6vw; height: 4.6vw; border-radius: 1.2vw; font-size: 2.2vw;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--accent, var(--o)) 85%, #fff 15%), var(--v)); color: #fff; }
+.al-ic img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
+.al-ic b { position: absolute; right: -.5vw; bottom: -.5vw; display: grid; place-items: center; width: 2vw; height: 2vw; border-radius: 50%; font-size: 1vw; background: var(--accent-strong, var(--accent, var(--o))); color: #fff; border: .2vw solid var(--bg-card, #111827); }
+.al-t { display: grid; gap: .25vw; min-width: 0; }
+.al-who { font-family: 'OV Brand', Nunito, Inter, sans-serif; font-weight: 900; font-size: 2.1vw; line-height: 1.1; letter-spacing: -.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.al-what { font-size: 1.35vw; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+.al-what em { font-style: normal; color: var(--accent-light, var(--accent, var(--o))); font-weight: 800; }
+.al-msg { margin-top: .3vw; font-size: 1.3vw; line-height: 1.35; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.al-msg:empty { display: none; }
+.al-status { position: fixed; left: 1vw; bottom: 1vw; font-size: .9vw; color: rgba(255,255,255,.55); text-shadow: 0 1px 2px #000; }
+.al-status:empty { display: none; }
+@media (prefers-reduced-motion: reduce) { .al, .al.is-out { animation: none; } .al::after { display: none; } }
+</style>
+<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
+<script src="/shared/theme-loader.js"></script>
+</head>
+<body>
+<div class="al-stage" id="al-stage" aria-live="polite"></div>
+<p class="al-status" id="al-status"></p>
+<script src="/shared/ov-fx.js"></script>
+<script>
+(function () {
+ 'use strict';
+ var parts = location.pathname.split('/').filter(Boolean); // obs, alerts, <username>
+ var user = /^[A-Za-z0-9_.-]{1,40}$/.test(parts[2] || '') ? parts[2] : '';
+ var q = new URLSearchParams(location.search);
+ var $ = function (id) { return document.getElementById(id); };
+ var L = window.OpenVibeThemeLoader;
+ var themeId = /^[a-z0-9-]{1,40}$/.test(q.get('theme') || '') ? q.get('theme') : 'vibe';
+ if (L && L.THEMES && L.THEMES[themeId]) { L.applyVars(L.THEMES[themeId]); document.documentElement.setAttribute('data-theme', themeId); }
+ document.documentElement.removeAttribute('data-ov-season'); document.documentElement.removeAttribute('data-ov-season-mark');
+ document.body.setAttribute('data-pos', q.get('pos') === 'bottom' ? 'bottom' : 'top');
+ var status = function (t) { $('al-status').textContent = t || ''; };
+
+ // ── One alert at a time, each for a few seconds; seen ids are never shown twice ──
+ var KIND = {
+ follow: { icon: 'fa-heart', say: 'followed' },
+ tip: { icon: 'fa-coins', say: 'tipped' },
+ sub: { icon: 'fa-star', say: 'subscribed' },
+ raid: { icon: 'fa-users', say: 'raided' },
+ goal: { icon: 'fa-trophy', say: 'Goal reached' },
+ };
+ var queue = [], showing = false, seen = new Set();
+ function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
+ function icon(name) { var i = document.createElement('i'); i.className = 'fa-solid ' + name; i.setAttribute('aria-hidden', 'true'); return i; }
+ function amountText(a) {
+ if (!a || !(Number(a.value) > 0)) return '';
+ var unit = String(a.unit || '').slice(0, 16);
+ return Number(a.value).toLocaleString() + (unit ? ' ' + unit : '');
+ }
+ function card(a) {
+ var k = KIND[a.kind];
+ var ic = el('div', 'al-ic');
+ var who = a.who && typeof a.who === 'object' ? a.who : null;
+ if (who && /^https:\/\//.test(who.avatar || '')) { var img = document.createElement('img'); img.alt = ''; img.onerror = function () { img.replaceWith(icon(k.icon)); }; img.src = who.avatar; ic.append(img); var badge = el('b'); badge.append(icon(k.icon)); ic.append(badge); }
+ else ic.append(icon(k.icon));
+ var name = a.kind === 'goal' ? 'Goal reached' : (who && who.name ? String(who.name).slice(0, 40) : 'Someone');
+ var what = el('p', 'al-what');
+ var amt = amountText(a.amount);
+ if (a.kind === 'goal') what.append(a.text ? String(a.text).slice(0, 80) : 'Thank you, everyone');
+ else if (a.kind === 'raid') { what.append('raided'); if (amt) { what.append(' with '); var e = el('em'); e.textContent = amt; what.append(e); } }
+ else { what.append(k.say); if (amt) { what.append(' '); var e2 = el('em'); e2.textContent = amt; what.append(e2); } }
+ var msg = el('p', 'al-msg');
+ if (a.kind === 'tip' && a.text) msg.textContent = String(a.text).slice(0, 200);
+ var t = el('div', 'al-t', [el('p', 'al-who', [name]), what, msg]);
+ return el('div', 'al', [ic, t]);
+ }
+ function next() {
+ if (showing || !queue.length) return;
+ showing = true;
+ var a = queue.shift(), node = card(a), stage = $('al-stage');
+ stage.replaceChildren(node);
+ var big = a.kind === 'goal' || a.kind === 'raid' || (a.kind === 'tip' && Number(a.amount && a.amount.value) >= 10) || a.kind === 'sub';
+ if (big && window.OVFx && window.OVFx.confetti) setTimeout(function () { var r = node.getBoundingClientRect(); window.OVFx.confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: a.kind === 'goal' ? 140 : 90 }); }, 450);
+ var stay = a.kind === 'tip' && a.text ? 8000 : 5500;
+ setTimeout(function () { node.classList.add('is-out'); setTimeout(function () { node.remove(); showing = false; next(); }, 460); }, stay);
+ }
+ function take(a) {
+ if (!a || !KIND[a.kind]) return;
+ var id = String(a.id || '');
+ if (id) { if (seen.has(id)) return; seen.add(id); if (seen.size > 500) seen.delete(seen.values().next().value); }
+ queue.push(a); if (queue.length > 20) queue.shift();
+ next();
+ }
+ window.OVAlerts = { take: take };
+
+ // ── Demo: a sample of each, to line up OBS ──
+ if (q.get('demo') === '1') {
+ var samples = [
+ { kind: 'follow', id: 'd1', who: { name: 'Wren' } },
+ { kind: 'tip', id: 'd2', who: { name: 'Maticus' }, amount: { value: 25, unit: 'Vibes' }, text: 'For the robot fund. Make it dance!' },
+ { kind: 'sub', id: 'd3', who: { name: 'JapaneseOldGuy' } },
+ { kind: 'raid', id: 'd4', who: { name: 'Goosely' }, amount: { value: 42, unit: 'viewers' } },
+ { kind: 'goal', id: 'd5', text: 'New camera: 100%' },
+ ];
+ var i = 0;
+ var loop = function () { var s = samples[i % samples.length]; take(Object.assign({}, s, { id: s.id + '-' + i })); i++; setTimeout(loop, 7000); };
+ loop();
+ status('Demo alerts');
+ return;
+ }
+
+ // ── The channel's room: overlay-alert messages, channel-wide, live or not ──
+ if (!user) { status('No channel in the address'); return; }
+ var ws = null, delay = 2000, streamId = null;
+ function resolve() {
+ return fetch('/api/streams/channel/' + encodeURIComponent(user)).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d || !d.channel) { status('Channel not found'); return false; }
+ var s = d.stream && d.stream.id ? d.stream : ((d.streams || [])[0] || null);
+ streamId = s && s.id ? s.id : null;
+ return true;
+ }).catch(function () { return false; });
+ }
+ function connect() {
+ var proto = location.protocol === 'https:' ? 'wss' : 'ws';
+ ws = new WebSocket(proto + '://' + location.host + '/ws/chat' + (streamId ? '?stream=' + streamId : ''));
+ ws.onopen = function () { delay = 2000; status(''); ws.send(JSON.stringify({ type: 'join', streamId: streamId, channel: user })); };
+ ws.onmessage = function (e) { try { var m = JSON.parse(e.data); if (m && m.type === 'overlay-alert') take(m); } catch (err) { /* not ours */ } };
+ ws.onclose = function () { status('Reconnecting…'); setTimeout(function () { resolve().then(connect); }, delay); delay = Math.min(delay * 1.6, 30000); };
+ }
+ resolve().then(function (ok) { if (ok) connect(); else setTimeout(function () { location.reload(); }, 30000); });
+ // The stream changes when it goes live or ends: join again then.
+ setInterval(function () {
+ var before = streamId;
+ resolve().then(function () { if (streamId !== before && ws && ws.readyState === 1) ws.close(); });
+ }, 60000);
+})();
+</script>
+</body>
+</html>
@@ -1050,6 +1050,11 @@ app.get(/^\/obs\/scene\/(starting|brb|ending)\/[A-Za-z0-9_.-]{1,40}\/?$/, (req,
@@ -1050,6 +1050,11 @@ app.get(/^\/obs\/scene\/(starting|brb|ending)\/[A-Za-z0-9_.-]{1,40}\/?$/, (req,
sendDocument(res, 'obs/scene.html');
sendDocument(res, 'obs/scene.html');
});
});
+// The moments of a stream in the streamer's look (plan L45): follows, tips, subs, raids and goals as they happen.
+app.get(/^\/obs\/alerts\/[A-Za-z0-9_.-]{1,40}\/?$/, (req, res) => {
+ sendDocument(res, 'obs/alerts.html');
+});
+
// New overlay routes — per-slot and global
// New overlay routes — per-slot and global
app.get('/overlay/chat/:username/:slotIdOrSlug', (req, res) => {
app.get('/overlay/chat/:username/:slotIdOrSlug', (req, res) => {
sendDocument(res, 'obs/chat.html');
sendDocument(res, 'obs/chat.html');
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+// The alerts overlay (plan L45 step 5): /obs/alerts/<username> shows a channel's moments (follow, tip, sub, raid, goal)
+// from `overlay-alert` messages on its chat room, one at a time, in the look given by name. Every value from a message
+// is text (a tipper's words can never be markup), ids are shown once, the queue is bounded, and demo=1 plays samples.
+// The page runs here on a tiny DOM.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/obs/alerts.html'), 'utf8');
+const index = fs.readFileSync(path.join(__dirname, '..', 'server/index.js'), 'utf8');
+const script = SRC.slice(SRC.lastIndexOf('<script>') + 8, SRC.lastIndexOf('</script>'));
+
+// ── The route ──
+const re = /^\/obs\/alerts\/[A-Za-z0-9_.-]{1,40}\/?$/;
+assert.ok(index.includes(`app.get(${re.toString()}`), 'the alerts route');
+assert.ok(re.test('/obs/alerts/goosely') && !re.test('/obs/alerts/<x>') && !re.test('/obs/alerts/'), 'a username only');
+
+// ── The page, statically ──
+assert.ok(!/innerHTML/.test(script), 'a message is only ever text');
+assert.match(SRC, /background: transparent !important/, 'a transparent ground over the stream');
+assert.match(SRC, /@media \(prefers-reduced-motion: reduce\)/);
+assert.match(script, /m\.type === 'overlay-alert'/, 'only overlay-alert messages');
+assert.match(script, /type: 'join', streamId: streamId, channel: user/, 'joins the channel\'s room, live or not');
+
+// ── The page, running on a tiny DOM ──
+class N {
+ constructor(t) { this.tagName = t; this.children = []; this.attrs = {}; this.className = ''; this._text = ''; this.style = {}; }
+ append(...k) { k.forEach((c) => this.children.push(typeof c === 'string' ? Object.assign(new N('#t'), { _text: c }) : c)); }
+ replaceChildren(...k) { this.children = []; this.append(...k); }
+ replaceWith() {} remove() { this.removed = true; }
+ setAttribute(k, v) { this.attrs[k] = String(v); } removeAttribute() {}
+ get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
+ set textContent(v) { this._text = String(v); this.children = []; }
+ getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 40 }; }
+}
+function run(href) {
+ const ids = { 'al-stage': new N('div'), 'al-status': new N('p') };
+ const timers = [];
+ const sockets = [];
+ const confetti = [];
+ const u = new URL(href);
+ const sandbox = {
+ location: { pathname: u.pathname, search: u.search, protocol: 'https:', host: 'openvibe.live', reload() {} },
+ URLSearchParams, Set, Number, String, Object, JSON, Math, Date,
+ document: { getElementById: (id) => ids[id], createElement: (t) => new N(t), documentElement: new N('html'), body: new N('body') },
+ setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
+ setInterval: () => 0,
+ fetch: async () => ({ ok: true, json: async () => ({ channel: { username: 'goosely' }, stream: { id: 7 } }) }),
+ WebSocket: class { constructor(url) { this.url = url; this.sent = []; sockets.push(this); } send(m) { this.sent.push(JSON.parse(m)); } close() {} },
+ };
+ sandbox.window = sandbox;
+ sandbox.OVFx = { confetti: (o) => confetti.push(o) };
+ vm.runInNewContext(script, sandbox);
+ const runTimers = (max = 50) => { for (let i = 0; i < max && timers.length; i++) timers.shift().fn(); };
+ return { ids, sandbox, sockets, confetti, runTimers, timers };
+}
+
+(async () => {
+ const p = run('https://openvibe.live/obs/alerts/goosely?theme=paper');
+ await new Promise((r) => setImmediate(r));
+ await new Promise((r) => setImmediate(r));
+ const ws = p.sockets[0];
+ assert.ok(ws && ws.url === 'wss://openvibe.live/ws/chat?stream=7', 'the channel\'s chat room');
+ ws.onopen();
+ assert.deepStrictEqual(ws.sent[0], { type: 'join', streamId: 7, channel: 'goosely' });
+
+ // A tip with words that try to be markup: shown as text.
+ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'tip', id: 't1', who: { name: 'Wren', avatar: 'javascript:alert(1)' }, amount: { value: 25, unit: 'Vibes' }, text: '<img src=x onerror=alert(1)>' }) });
+ const stage = p.ids['al-stage'];
+ assert.match(stage.textContent, /^Wren/);
+ assert.match(stage.textContent, /tipped 25 Vibes/);
+ assert.ok(stage.textContent.includes('<img src=x onerror=alert(1)>'), 'the words, as words');
+ const imgs = []; const walk = (n) => { if (n.tagName === 'img') imgs.push(n); n.children.forEach(walk); }; walk(stage);
+ assert.strictEqual(imgs.length, 0, 'only an https avatar is shown');
+
+ // The same id twice: shown once. Other messages: ignored.
+ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'tip', id: 't1', who: { name: 'Wren' } }) });
+ ws.onmessage({ data: JSON.stringify({ type: 'chat', message: 'hi' }) });
+ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'nonsense', id: 'x' }) });
+ ws.onmessage({ data: JSON.stringify({ type: 'overlay-alert', kind: 'follow', id: 'f1', who: { name: 'Goosely' } }) });
+ p.runTimers();
+ assert.match(stage.textContent, /Goosely.*followed/, 'next in line: the follow, once the tip has had its time');
+ assert.ok(p.confetti.length >= 1, 'a tip of 10 or more throws confetti');
+
+ // Demo mode plays samples without a channel or a socket.
+ const d = run('https://openvibe.live/obs/alerts/goosely?demo=1');
+ assert.strictEqual(d.sockets.length, 0);
+ assert.match(d.ids['al-stage'].textContent, /Wren.*followed/);
+ console.log('obs alerts: the channel room, text only, once per id, a queue, confetti for the big ones, a demo');
+})().catch((e) => { console.error(e); process.exit(1); });