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>
200 lines
8.3 KiB
HTML
200 lines
8.3 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Layout Editor — BashyOverlay{% endblock %}
|
|
{% block content %}
|
|
<hgroup>
|
|
<h1>Layout Editor</h1>
|
|
<p>Drag handles to reposition. Resize chat from the bottom-right corner. Changes to styles preview live.</p>
|
|
</hgroup>
|
|
|
|
{% if saved %}
|
|
<div class="notice-success">Styles saved. Refresh your OBS browser source to apply.</div>
|
|
{% endif %}
|
|
|
|
<div class="editor-wrap" id="editor-wrap">
|
|
<iframe id="preview-frame"
|
|
src="/overlay/preview"
|
|
scrolling="no"
|
|
style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;pointer-events:none;">
|
|
</iframe>
|
|
|
|
<div class="drag-handle handle-chat"
|
|
id="handle-chat"
|
|
data-target="chat"
|
|
data-left="{{ layout.chat_left }}"
|
|
data-top="{{ layout.chat_top }}"
|
|
data-width="{{ layout.chat_width }}"
|
|
data-height="{{ layout.chat_height }}"
|
|
style="left:{{ layout.chat_left }}%;top:{{ layout.chat_top }}%;width:{{ layout.chat_width }}%;height:{{ layout.chat_height }}%">
|
|
<div class="handle-label">Chat</div>
|
|
<div class="resize-corner"></div>
|
|
</div>
|
|
|
|
<div class="drag-handle handle-alert"
|
|
id="handle-alert"
|
|
data-target="alert"
|
|
data-left="{{ layout.alert_left }}"
|
|
data-top="{{ layout.alert_top }}"
|
|
style="left:{{ layout.alert_left }}%;top:{{ layout.alert_top }}%;transform:translate(-50%,-50%)">
|
|
<div class="handle-label">Alert</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="canvas-footer">
|
|
<small class="muted">Positions save on drag end. Refresh OBS browser source after repositioning.</small>
|
|
<button type="button" id="preview-alert-btn" class="outline small-btn">▶ Preview alert</button>
|
|
</div>
|
|
|
|
<form method="post" action="/layout" class="style-form">
|
|
<div class="props-grid">
|
|
<fieldset>
|
|
<legend><strong>Chat</strong></legend>
|
|
<div class="props-row">
|
|
<label>
|
|
Font size (px)
|
|
<input type="number" name="chat_font_size" value="{{ layout.chat_font_size }}" min="8" max="48">
|
|
</label>
|
|
<label>
|
|
BG opacity
|
|
<input type="range" name="chat_bg_opacity" value="{{ layout.chat_bg_opacity }}"
|
|
min="0" max="1" step="0.05"
|
|
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
|
|
<span>{{ layout.chat_bg_opacity }}</span>
|
|
</label>
|
|
<label>
|
|
Text color
|
|
<input type="color" name="chat_text_color" value="{{ layout.chat_text_color }}">
|
|
</label>
|
|
<label>
|
|
Max messages
|
|
<input type="number" name="chat_max_messages" value="{{ layout.chat_max_messages }}" min="1" max="50">
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend><strong>Alert</strong></legend>
|
|
<div class="props-row">
|
|
<label>
|
|
Font size (px)
|
|
<input type="number" name="alert_font_size" value="{{ layout.alert_font_size }}" min="8" max="72">
|
|
</label>
|
|
<label>
|
|
BG opacity
|
|
<input type="range" name="alert_bg_opacity" value="{{ layout.alert_bg_opacity }}"
|
|
min="0" max="1" step="0.05"
|
|
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
|
|
<span>{{ layout.alert_bg_opacity }}</span>
|
|
</label>
|
|
<label>
|
|
Text color
|
|
<input type="color" name="alert_text_color" value="{{ layout.alert_text_color }}">
|
|
</label>
|
|
<label>
|
|
Duration (sec)
|
|
<input type="number" name="alert_duration" value="{{ layout.alert_duration }}" min="1" max="30">
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
<button type="submit">Save styles</button>
|
|
</form>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
|
|
<script>
|
|
const wrap = document.getElementById('editor-wrap');
|
|
const iframe = document.getElementById('preview-frame');
|
|
|
|
function iframeDoc() { return iframe.contentDocument || iframe.contentWindow?.document; }
|
|
|
|
function iframeVar(cssVar, value) {
|
|
const doc = iframeDoc();
|
|
if (doc) doc.documentElement.style.setProperty(cssVar, value);
|
|
}
|
|
|
|
// ── Live style preview ────────────────────────────────────────────────────
|
|
function bind(name, cssVar, transform = v => v) {
|
|
const el = document.querySelector(`[name="${name}"]`);
|
|
if (!el) return;
|
|
el.addEventListener('input', () => iframeVar(cssVar, transform(el.value)));
|
|
}
|
|
|
|
bind('chat_font_size', '--chat-font-size', v => v + 'px');
|
|
bind('chat_bg_opacity', '--chat-bg-opacity');
|
|
bind('chat_text_color', '--chat-text-color');
|
|
bind('alert_font_size', '--alert-font-size', v => v + 'px');
|
|
bind('alert_bg_opacity', '--alert-bg-opacity');
|
|
bind('alert_text_color', '--alert-text-color');
|
|
|
|
// ── Preview alert button ──────────────────────────────────────────────────
|
|
document.getElementById('preview-alert-btn').addEventListener('click', () => {
|
|
iframe.contentWindow?.postMessage({ type: 'preview-alert' }, '*');
|
|
});
|
|
|
|
// ── Drag ──────────────────────────────────────────────────────────────────
|
|
interact('.drag-handle').draggable({
|
|
listeners: {
|
|
move(event) {
|
|
const el = event.target;
|
|
const ww = wrap.offsetWidth;
|
|
const wh = wrap.offsetHeight;
|
|
let left = parseFloat(el.dataset.left || 0);
|
|
let top = parseFloat(el.dataset.top || 0);
|
|
left = Math.max(0, Math.min(95, left + event.dx / ww * 100));
|
|
top = Math.max(0, Math.min(95, top + event.dy / wh * 100));
|
|
el.style.left = left + '%';
|
|
el.style.top = top + '%';
|
|
el.dataset.left = left;
|
|
el.dataset.top = top;
|
|
const t = el.dataset.target;
|
|
iframeVar(`--${t}-left`, left + '%');
|
|
iframeVar(`--${t}-top`, top + '%');
|
|
},
|
|
end(event) {
|
|
const el = event.target;
|
|
savePos(el.dataset.target,
|
|
parseFloat(el.dataset.left),
|
|
parseFloat(el.dataset.top),
|
|
parseFloat(el.dataset.width || 0),
|
|
parseFloat(el.dataset.height || 0));
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Resize (chat only) ────────────────────────────────────────────────────
|
|
interact('#handle-chat').resizable({
|
|
edges: { right: true, bottom: true },
|
|
modifiers: [interact.modifiers.restrictSize({ min: { width: 30, height: 30 } })],
|
|
listeners: {
|
|
move(event) {
|
|
const el = event.target;
|
|
const ww = wrap.offsetWidth;
|
|
const wh = wrap.offsetHeight;
|
|
const w = Math.max(5, Math.min(100, event.rect.width / ww * 100));
|
|
const h = Math.max(5, Math.min(100, event.rect.height / wh * 100));
|
|
el.style.width = w + '%';
|
|
el.style.height = h + '%';
|
|
el.dataset.width = w;
|
|
el.dataset.height = h;
|
|
iframeVar('--chat-width', w + '%');
|
|
iframeVar('--chat-height', h + '%');
|
|
},
|
|
end(event) {
|
|
const el = event.target;
|
|
savePos('chat',
|
|
parseFloat(el.dataset.left),
|
|
parseFloat(el.dataset.top),
|
|
parseFloat(el.dataset.width),
|
|
parseFloat(el.dataset.height));
|
|
}
|
|
}
|
|
});
|
|
|
|
async function savePos(target, left, top, width, height) {
|
|
await fetch('/layout/positions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ widget: target, left, top, width, height })
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|