heystreamer/templates/overlay.html
Bashy 6ad661c220 Initial commit — BashyOverlay v1
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>
2026-04-22 23:48:35 +03:00

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── 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>