Twitch stream event platform with OBS browser source overlay. Features: - Twitch OAuth login (streamer + mod access) - EventSub WebSocket for subs, gift subs, bits, channel points, follows, raids - Chat commands via tmi.js in the overlay - Gap audio — tone plays on new chat message after silence, volume/pitch scale with gap length - Self-hosted media library (upload sounds and videos) - Action configuration — map any event to sound, video, or alert - Per-action cooldowns, test button, enable/disable toggle - Multiple actions per event (all matching actions fire) - Activity log dashboard with EventSub connection status - Layout editor — iframe-based WYSIWYG with drag handles, live style preview - Custom CSS and custom JS injection into overlay - Custom DOM events (bashyoverlay:sub, bashyoverlay:raid, etc.) for custom JS hooks - deploy.sh — one-shot setup and launch script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
6.3 KiB
HTML
162 lines
6.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Overlay</title>
|
|
<link rel="stylesheet" href="/static/overlay.css">
|
|
{% if custom_css %}<style>{{ custom_css | safe }}</style>{% endif %}
|
|
<style>
|
|
:root {
|
|
--chat-left: {{ layout.chat_left }}%;
|
|
--chat-top: {{ layout.chat_top }}%;
|
|
--chat-width: {{ layout.chat_width }}%;
|
|
--chat-height: {{ layout.chat_height }}%;
|
|
--chat-font-size: {{ layout.chat_font_size }}px;
|
|
--chat-bg-opacity: {{ layout.chat_bg_opacity }};
|
|
--chat-text-color: {{ layout.chat_text_color }};
|
|
--alert-left: {{ layout.alert_left }}%;
|
|
--alert-top: {{ layout.alert_top }}%;
|
|
--alert-font-size: {{ layout.alert_font_size }}px;
|
|
--alert-bg-opacity: {{ layout.alert_bg_opacity }};
|
|
--alert-text-color: {{ layout.alert_text_color }};
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="alert-video-wrap" style="display:none">
|
|
<video id="alert-video" playsinline></video>
|
|
</div>
|
|
<div id="alert" style="display:none"></div>
|
|
<div id="chat"></div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/tmi.js@1.8.5/build/tmi.min.js"></script>
|
|
<script>
|
|
const CHANNEL = {{ channel | tojson }};
|
|
const GAP_SIL = {{ gap_silence }};
|
|
const GAP_VOL = {{ gap_scale_end }};
|
|
const GAP_PITCH = {{ gap_pitch_end }};
|
|
const MAX_MSG = {{ layout.chat_max_messages }};
|
|
const ALERT_DURATION = {{ layout.alert_duration * 1000 }};
|
|
|
|
let lastMsgTime = Date.now();
|
|
let alertQueue = [];
|
|
let alertBusy = false;
|
|
|
|
// ── WebSocket to backend ──────────────────────────────────────────────
|
|
let ws;
|
|
function connectWS() {
|
|
ws = new WebSocket(`ws://${location.host}/ws/overlay`);
|
|
ws.onmessage = e => {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'execute') {
|
|
document.dispatchEvent(new CustomEvent(
|
|
`bashyoverlay:${msg.event_type || 'event'}`,
|
|
{ detail: msg }
|
|
));
|
|
enqueueAlert(msg);
|
|
}
|
|
};
|
|
ws.onclose = () => setTimeout(connectWS, 3000);
|
|
}
|
|
connectWS();
|
|
|
|
// ── Gap audio (Web Audio API — no file needed) ────────────────────────
|
|
function playGapBeep(gapSec) {
|
|
if (gapSec < GAP_SIL) return;
|
|
const vol = Math.min(1, (gapSec - GAP_SIL) / Math.max(1, GAP_VOL - GAP_SIL));
|
|
const pitch = 1 + Math.min(1, Math.max(0, (gapSec - GAP_VOL) / Math.max(1, GAP_PITCH - GAP_VOL)));
|
|
const ctx = new AudioContext();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.frequency.value = 440 * pitch;
|
|
gain.gain.value = vol * 0.35;
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.25);
|
|
setTimeout(() => ctx.close(), 500);
|
|
}
|
|
|
|
// ── tmi.js chat ───────────────────────────────────────────────────────
|
|
const client = new tmi.Client({ channels: [CHANNEL] });
|
|
client.connect().catch(console.error);
|
|
|
|
client.on('message', (channel, tags, message, self) => {
|
|
const now = Date.now();
|
|
playGapBeep((now - lastMsgTime) / 1000);
|
|
lastMsgTime = now;
|
|
|
|
addChatMsg(tags['display-name'] || tags.username, message, tags.color);
|
|
|
|
if (message.startsWith('!') && ws.readyState === WebSocket.OPEN) {
|
|
const name = message.split(' ')[0].slice(1).toLowerCase();
|
|
if (name) ws.send(JSON.stringify({ type: 'command', name, user: tags['display-name'] || tags.username }));
|
|
}
|
|
});
|
|
|
|
// ── Chat display ──────────────────────────────────────────────────────
|
|
const chatEl = document.getElementById('chat');
|
|
|
|
function addChatMsg(username, message, color) {
|
|
const el = document.createElement('div');
|
|
el.className = 'chat-msg';
|
|
el.innerHTML = `<span class="uname" style="color:${color || '#9b59b6'}">${esc(username)}</span>: ${esc(message)}`;
|
|
chatEl.appendChild(el);
|
|
while (chatEl.children.length > MAX_MSG) chatEl.firstChild.remove();
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// ── Alert queue ───────────────────────────────────────────────────────
|
|
function enqueueAlert(msg) {
|
|
alertQueue.push(msg);
|
|
if (!alertBusy) nextAlert();
|
|
}
|
|
|
|
function nextAlert() {
|
|
if (!alertQueue.length) { alertBusy = false; return; }
|
|
alertBusy = true;
|
|
playAlert(alertQueue.shift());
|
|
}
|
|
|
|
function playAlert(msg) {
|
|
const alertEl = document.getElementById('alert');
|
|
const videoWrap = document.getElementById('alert-video-wrap');
|
|
const videoEl = document.getElementById('alert-video');
|
|
|
|
if (msg.alert_text) {
|
|
alertEl.textContent = msg.alert_text;
|
|
alertEl.style.display = 'block';
|
|
}
|
|
|
|
if (msg.action_type === 'sound' && msg.media_url) {
|
|
new Audio(msg.media_url).play().catch(() => {});
|
|
}
|
|
|
|
if (msg.action_type === 'video' && msg.media_url) {
|
|
videoEl.src = msg.media_url;
|
|
videoWrap.style.display = 'block';
|
|
videoEl.play().catch(() => {});
|
|
videoEl.onended = () => {
|
|
videoWrap.style.display = 'none';
|
|
videoEl.src = '';
|
|
alertEl.style.display = 'none';
|
|
alertEl.textContent = '';
|
|
nextAlert();
|
|
};
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
alertEl.style.display = 'none';
|
|
alertEl.textContent = '';
|
|
nextAlert();
|
|
}, ALERT_DURATION);
|
|
}
|
|
</script>
|
|
{% if custom_js %}<script>{{ custom_js | safe }}</script>{% endif %}
|
|
</body>
|
|
</html>
|