parent
c46f2f2fc3
commit
6c7facadce
1831 changed files with 477243 additions and 2607 deletions
88
views/actions.ejs
Normal file
88
views/actions.ejs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<h2>Actions</h2>
|
||||
<p><small>Map Twitch events to sounds, videos, or on-screen alerts.</small></p>
|
||||
|
||||
<details>
|
||||
<summary>Add new action</summary>
|
||||
<form method="POST" action="/actions" style="margin-top:1rem;">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||
<label>Name <input type="text" name="name" required placeholder="e.g. Sub sound"></label>
|
||||
<label>
|
||||
Event type
|
||||
<select name="event_type" required>
|
||||
<option value="sub">Sub</option>
|
||||
<option value="gift_sub">Gift sub</option>
|
||||
<option value="bits">Bits</option>
|
||||
<option value="channel_points">Channel points</option>
|
||||
<option value="command">Chat command</option>
|
||||
<option value="follow">Follow</option>
|
||||
<option value="raid">Raid</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Event detail <small>(tier / min bits / command name / reward ID)</small>
|
||||
<input type="text" name="event_detail" placeholder="Optional">
|
||||
</label>
|
||||
<label>
|
||||
Action type
|
||||
<select name="action_type" required>
|
||||
<option value="sound">Sound</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="alert">Alert text</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Media file
|
||||
<select name="media_file_id">
|
||||
<option value="">— none —</option>
|
||||
<% for (const f of media) { %>
|
||||
<option value="<%= f.id %>"><%= f.original_name %> (<%= f.media_type %>)</option>
|
||||
<% } %>
|
||||
</select>
|
||||
</label>
|
||||
<label>Alert text <input type="text" name="alert_text" placeholder="{username} just subbed!"></label>
|
||||
<label>Cooldown (seconds) <input type="number" name="cooldown_seconds" value="0" min="0"></label>
|
||||
</div>
|
||||
<button type="submit">Add action</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<% if (actions.length === 0) { %>
|
||||
<p><em>No actions yet.</em></p>
|
||||
<% } else { %>
|
||||
<figure>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Event</th>
|
||||
<th>Detail</th>
|
||||
<th>Action</th>
|
||||
<th>Cooldown</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const a of actions) { %>
|
||||
<tr>
|
||||
<td><%= a.name %></td>
|
||||
<td><code><%= a.event_type %></code></td>
|
||||
<td><%= a.event_detail || '—' %></td>
|
||||
<td><code><%= a.action_type %></code></td>
|
||||
<td><%= a.cooldown_seconds %>s</td>
|
||||
<td>
|
||||
<span class="badge <%= a.enabled ? 'badge-green' : 'badge-grey' %>">
|
||||
<%= a.enabled ? 'on' : 'off' %>
|
||||
</span>
|
||||
</td>
|
||||
<td style="display:flex; gap:0.5rem;">
|
||||
<form method="POST" action="/actions/<%= a.id %>/test"><button type="submit" class="outline" style="padding:0.2rem 0.6rem; font-size:0.8rem;">Test</button></form>
|
||||
<form method="POST" action="/actions/<%= a.id %>/toggle"><button type="submit" class="outline" style="padding:0.2rem 0.6rem; font-size:0.8rem;"><%= a.enabled ? 'Disable' : 'Enable' %></button></form>
|
||||
<form method="POST" action="/actions/<%= a.id %>/delete" onsubmit="return confirm('Delete this action?')"><button type="submit" class="outline" style="padding:0.2rem 0.6rem; font-size:0.8rem; color:#f88;">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<% } %>
|
||||
46
views/dashboard.ejs
Normal file
46
views/dashboard.ejs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<h2>Dashboard</h2>
|
||||
|
||||
<div style="display:flex; gap:1rem; margin-bottom:1.5rem; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong>Channel:</strong>
|
||||
<%= settings.channel || '<em>not set</em>' %>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Overlay URL:</strong>
|
||||
<code><%= process.env.APP_BASE_URL %>/overlay/<%= settings.overlay_id %></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Recent activity</h3>
|
||||
<% if (logs.length === 0) { %>
|
||||
<p><em>No activity yet. Events will appear here once your overlay is connected and Twitch fires events.</em></p>
|
||||
<% } else { %>
|
||||
<figure>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Event</th>
|
||||
<th>User</th>
|
||||
<th>Detail</th>
|
||||
<th>Actions fired</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const log of logs) { %>
|
||||
<tr>
|
||||
<td><small><%= log.created_at %></small></td>
|
||||
<td><code><%= log.event_type %></code></td>
|
||||
<td><%= log.username || '—' %></td>
|
||||
<td><%= log.event_detail || '—' %></td>
|
||||
<td>
|
||||
<span class="badge <%= log.actions_fired > 0 ? 'badge-green' : 'badge-grey' %>">
|
||||
<%= log.actions_fired %>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<% } %>
|
||||
38
views/layout.ejs
Normal file
38
views/layout.ejs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BashyOverlay</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
nav.container-fluid { padding-block: 0.5rem; }
|
||||
.nav-brand { font-weight: 700; font-size: 1.1rem; text-decoration: none; color: inherit; }
|
||||
nav ul li a { font-size: 0.9rem; }
|
||||
.badge { display: inline-block; padding: 0.15em 0.5em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-green { background: #1a7a1a; color: #a8f0a8; }
|
||||
.badge-red { background: #7a1a1a; color: #f0a8a8; }
|
||||
.badge-grey { background: #333; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container-fluid">
|
||||
<ul>
|
||||
<li><a class="nav-brand" href="/">BashyOverlay</a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/actions">Actions</a></li>
|
||||
<li><a href="/media">Media</a></li>
|
||||
<li><a href="/layout">Layout</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<% if (locals.user) { %>
|
||||
<li><a href="/auth/logout">Logout (<%= user.login %>)</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="container">
|
||||
<%- body %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
91
views/layout_editor.ejs
Normal file
91
views/layout_editor.ejs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<h2>Layout editor</h2>
|
||||
<p><small>Drag the handles in the preview to reposition widgets. Use the form below to adjust style.</small></p>
|
||||
|
||||
<div style="position:relative; width:100%; padding-bottom:56.25%; border:1px solid #333; border-radius:8px; overflow:hidden; margin-bottom:1.5rem;">
|
||||
<iframe
|
||||
id="preview"
|
||||
src="/overlay/<%= settings.overlay_id %>/preview"
|
||||
style="position:absolute; top:0; left:0; width:1920px; height:1080px; border:none; transform-origin:top left;"
|
||||
scrolling="no"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/layout">
|
||||
<h3>Chat style</h3>
|
||||
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:1rem;">
|
||||
<label>Font size (px) <input type="number" name="chat_font_size" value="<%= layout.chat_font_size %>" min="8" max="64"></label>
|
||||
<label>Font family <input type="text" name="chat_font_family" value="<%= layout.chat_font_family %>"></label>
|
||||
<label>Text colour <input type="color" name="chat_text_color" value="<%= layout.chat_text_color %>"></label>
|
||||
<label>Background opacity (0–1) <input type="number" name="chat_bg_opacity" value="<%= layout.chat_bg_opacity %>" min="0" max="1" step="0.05"></label>
|
||||
<label>Border radius (px) <input type="number" name="chat_border_radius" value="<%= layout.chat_border_radius %>" min="0" max="32"></label>
|
||||
</div>
|
||||
|
||||
<h3>Alert style</h3>
|
||||
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:1rem;">
|
||||
<label>Font size (px) <input type="number" name="alert_font_size" value="<%= layout.alert_font_size %>" min="12" max="96"></label>
|
||||
<label>Text colour <input type="color" name="alert_text_color" value="<%= layout.alert_text_color %>"></label>
|
||||
<label>Background opacity (0–1) <input type="number" name="alert_bg_opacity" value="<%= layout.alert_bg_opacity %>" min="0" max="1" step="0.05"></label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save style</button>
|
||||
</form>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/interact.js@1.10.27/dist/interact.min.js"></script>
|
||||
<script>
|
||||
const iframe = document.getElementById('preview');
|
||||
|
||||
function applyScale() {
|
||||
const scale = iframe.parentElement.offsetWidth / 1920;
|
||||
iframe.style.transform = `scale(${scale})`;
|
||||
}
|
||||
applyScale();
|
||||
window.addEventListener('resize', applyScale);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
const iDoc = iframe.contentDocument;
|
||||
const scale = iframe.parentElement.offsetWidth / 1920;
|
||||
|
||||
['chat', 'alert'].forEach(widget => {
|
||||
const handle = iDoc.getElementById(`${widget}-handle`);
|
||||
if (!handle) return;
|
||||
|
||||
interact(handle).draggable({
|
||||
listeners: {
|
||||
move(event) {
|
||||
const rect = iDoc.documentElement;
|
||||
const x = parseFloat(getComputedStyle(handle).left) + event.dx / scale;
|
||||
const y = parseFloat(getComputedStyle(handle).top) + event.dy / scale;
|
||||
const left = Math.max(0, Math.min(x / 1920 * 100, 95));
|
||||
const top = Math.max(0, Math.min(y / 1080 * 100, 95));
|
||||
|
||||
if (widget === 'chat') {
|
||||
iDoc.documentElement.style.setProperty('--chat-left', `${left}%`);
|
||||
iDoc.documentElement.style.setProperty('--chat-top', `${top}%`);
|
||||
} else {
|
||||
iDoc.documentElement.style.setProperty('--alert-left', `${left}%`);
|
||||
iDoc.documentElement.style.setProperty('--alert-top', `${top}%`);
|
||||
}
|
||||
},
|
||||
end() { savePositions(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function savePositions() {
|
||||
const iDoc = iframe.contentDocument;
|
||||
const get = (v) => parseFloat(getComputedStyle(iDoc.documentElement).getPropertyValue(v));
|
||||
fetch('/layout/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_left: get('--chat-left') || <%= layout.chat_left %>,
|
||||
chat_top: get('--chat-top') || <%= layout.chat_top %>,
|
||||
chat_width: <%= layout.chat_width %>,
|
||||
chat_height: <%= layout.chat_height %>,
|
||||
alert_left: get('--alert-left') || <%= layout.alert_left %>,
|
||||
alert_top: get('--alert-top') || <%= layout.alert_top %>,
|
||||
}),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
33
views/login.ejs
Normal file
33
views/login.ejs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BashyOverlay — Login</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { width: 100%; max-width: 380px; text-align: center; }
|
||||
.login-box h1 { margin-bottom: 0.25rem; }
|
||||
.login-box p { color: var(--pico-muted-color); margin-bottom: 2rem; }
|
||||
.twitch-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
background: #9146ff; color: #fff; border: none;
|
||||
padding: 0.75rem 1.5rem; border-radius: 6px;
|
||||
font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.twitch-btn:hover { background: #7c2ff5; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>BashyOverlay</h1>
|
||||
<p>Self-hosted Twitch stream overlay</p>
|
||||
<a class="twitch-btn" href="/auth/twitch">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/></svg>
|
||||
Login with Twitch
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
53
views/media.ejs
Normal file
53
views/media.ejs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<h2>Media library</h2>
|
||||
<p><small>Upload audio (MP3, WAV, OGG) or video (MP4, WebM) files to use in actions. Max 50 MB.</small></p>
|
||||
|
||||
<form method="POST" action="/media/upload" enctype="multipart/form-data">
|
||||
<div style="display:flex; gap:1rem; align-items:flex-end;">
|
||||
<label style="flex:1;">
|
||||
File
|
||||
<input type="file" name="file" accept="audio/*,video/*" required>
|
||||
</label>
|
||||
<button type="submit">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if (files.length === 0) { %>
|
||||
<p><em>No files uploaded yet.</em></p>
|
||||
<% } else { %>
|
||||
<figure>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Preview</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const f of files) { %>
|
||||
<tr>
|
||||
<td><%= f.original_name %></td>
|
||||
<td><code><%= f.media_type %></code></td>
|
||||
<td><%= (f.size_bytes / 1024).toFixed(0) %> KB</td>
|
||||
<td><small><%= f.uploaded_at %></small></td>
|
||||
<td>
|
||||
<% if (f.media_type === 'audio') { %>
|
||||
<audio controls src="/media/<%= f.filename %>" style="height:28px; width:160px;"></audio>
|
||||
<% } else { %>
|
||||
<video controls src="/media/<%= f.filename %>" style="height:40px; width:80px;"></video>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/media/<%= f.id %>/delete" onsubmit="return confirm('Delete this file?')">
|
||||
<button type="submit" class="outline" style="padding:0.2rem 0.6rem; font-size:0.8rem; color:#f88;">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<% } %>
|
||||
193
views/overlay.ejs
Normal file
193
views/overlay.ejs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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-font-family: <%= layout.chat_font_family %>;
|
||||
--chat-bg-opacity: <%= layout.chat_bg_opacity %>;
|
||||
--chat-text-color: <%= layout.chat_text_color %>;
|
||||
--chat-border-radius: <%= layout.chat_border_radius %>px;
|
||||
--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 %>;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: transparent; overflow: hidden; width: 1920px; height: 1080px; }
|
||||
|
||||
#chat {
|
||||
position: fixed;
|
||||
left: var(--chat-left);
|
||||
top: var(--chat-top);
|
||||
width: var(--chat-width);
|
||||
max-height: var(--chat-height);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: var(--chat-font-family);
|
||||
font-size: var(--chat-font-size);
|
||||
color: var(--chat-text-color);
|
||||
}
|
||||
.chat-msg {
|
||||
background: rgba(0, 0, 0, var(--chat-bg-opacity));
|
||||
border-radius: var(--chat-border-radius);
|
||||
padding: 5px 10px;
|
||||
word-break: break-word;
|
||||
animation: fadeIn 0.25s ease;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.chat-msg .username { font-weight: 700; margin-right: 4px; }
|
||||
|
||||
#alert-box {
|
||||
position: fixed;
|
||||
left: var(--alert-left);
|
||||
top: var(--alert-top);
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--chat-font-family);
|
||||
font-size: var(--alert-font-size);
|
||||
color: var(--alert-text-color);
|
||||
background: rgba(0, 0, 0, var(--alert-bg-opacity));
|
||||
border-radius: 12px;
|
||||
padding: 16px 28px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
#alert-video {
|
||||
position: fixed;
|
||||
left: var(--alert-left);
|
||||
top: var(--alert-top);
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 640px;
|
||||
max-height: 480px;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
<%- settings.custom_css %>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat"></div>
|
||||
<div id="alert-box"></div>
|
||||
<video id="alert-video" autoplay></video>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/tmi.js@1.8.5/build/tmi.min.js"></script>
|
||||
<script>
|
||||
const S = <%- JSON.stringify(settings) %>;
|
||||
const chat = document.getElementById('chat');
|
||||
const alertBox = document.getElementById('alert-box');
|
||||
const alertVideo = document.getElementById('alert-video');
|
||||
|
||||
let lastMsgTime = 0;
|
||||
|
||||
// --- Chat via tmi.js ---
|
||||
if (S.channel) {
|
||||
const client = new tmi.Client({ channels: [S.channel] });
|
||||
client.connect().catch(console.error);
|
||||
|
||||
client.on('message', (channel, tags, message) => {
|
||||
const now = Date.now();
|
||||
const silence = (now - lastMsgTime) / 1000;
|
||||
|
||||
if (S.gap_audio_enabled && S.gap_audio_url && lastMsgTime > 0 && silence >= S.gap_audio_min_silence) {
|
||||
const t = Math.min(
|
||||
(silence - S.gap_audio_min_silence) / Math.max(S.gap_audio_loud_after - S.gap_audio_min_silence, 1),
|
||||
1
|
||||
);
|
||||
const vol = S.gap_audio_min_volume + t * (S.gap_audio_max_volume - S.gap_audio_min_volume);
|
||||
playAudio(S.gap_audio_url, vol);
|
||||
}
|
||||
|
||||
lastMsgTime = now;
|
||||
addMessage(tags['display-name'] || tags.username, tags.color || '#b4a0d4', message);
|
||||
});
|
||||
}
|
||||
|
||||
function addMessage(username, color, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.innerHTML = `<span class="username" style="color:${esc(color)}">${esc(username)}</span>: ${esc(text)}`;
|
||||
chat.appendChild(div);
|
||||
|
||||
while (chat.children.length > S.max_messages) chat.removeChild(chat.firstChild);
|
||||
|
||||
if (S.message_timeout > 0) {
|
||||
setTimeout(() => {
|
||||
div.style.opacity = '0';
|
||||
setTimeout(() => div.remove(), 500);
|
||||
}, S.message_timeout * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Alert WebSocket ---
|
||||
function connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/overlay`);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
document.dispatchEvent(new CustomEvent(`bashyoverlay:${msg.event_type}`, { detail: msg }));
|
||||
handleAction(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => setTimeout(connectWS, 3000);
|
||||
}
|
||||
connectWS();
|
||||
|
||||
function handleAction(msg) {
|
||||
if (msg.action_type === 'sound' && msg.media_url) {
|
||||
playAudio(msg.media_url, 1.0);
|
||||
} else if (msg.action_type === 'video' && msg.media_url) {
|
||||
showVideo(msg.media_url);
|
||||
} else if (msg.action_type === 'alert') {
|
||||
showAlert(msg.alert_text || '', msg.username);
|
||||
}
|
||||
}
|
||||
|
||||
function playAudio(url, volume) {
|
||||
const a = new Audio(url);
|
||||
a.volume = Math.max(0, Math.min(1, volume));
|
||||
a.play().catch(() => {});
|
||||
}
|
||||
|
||||
function showAlert(text, username) {
|
||||
alertBox.textContent = text.replace('{username}', username || '');
|
||||
alertBox.style.display = 'block';
|
||||
setTimeout(() => { alertBox.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
function showVideo(url) {
|
||||
alertVideo.src = url;
|
||||
alertVideo.style.display = 'block';
|
||||
alertVideo.play();
|
||||
alertVideo.onended = () => {
|
||||
alertVideo.style.display = 'none';
|
||||
alertVideo.src = '';
|
||||
};
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
<%- settings.custom_js %>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
87
views/overlay_preview.ejs
Normal file
87
views/overlay_preview.ejs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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-font-family: <%= layout.chat_font_family %>;
|
||||
--chat-bg-opacity: <%= layout.chat_bg_opacity %>;
|
||||
--chat-text-color: <%= layout.chat_text_color %>;
|
||||
--chat-border-radius: <%= layout.chat_border_radius %>px;
|
||||
--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 %>;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: rgba(30,30,30,0.6); overflow: hidden; width: 1920px; height: 1080px; }
|
||||
|
||||
#chat {
|
||||
position: fixed;
|
||||
left: var(--chat-left);
|
||||
top: var(--chat-top);
|
||||
width: var(--chat-width);
|
||||
max-height: var(--chat-height);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: var(--chat-font-family);
|
||||
font-size: var(--chat-font-size);
|
||||
color: var(--chat-text-color);
|
||||
}
|
||||
.chat-msg {
|
||||
background: rgba(0, 0, 0, var(--chat-bg-opacity));
|
||||
border-radius: var(--chat-border-radius);
|
||||
padding: 5px 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-msg .username { font-weight: 700; margin-right: 4px; }
|
||||
|
||||
#alert-box {
|
||||
position: fixed;
|
||||
left: var(--alert-left);
|
||||
top: var(--alert-top);
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--chat-font-family);
|
||||
font-size: var(--alert-font-size);
|
||||
color: var(--alert-text-color);
|
||||
background: rgba(0, 0, 0, var(--alert-bg-opacity));
|
||||
border-radius: 12px;
|
||||
padding: 16px 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#chat-handle, #alert-handle {
|
||||
position: fixed;
|
||||
border: 2px dashed rgba(255,255,255,0.5);
|
||||
cursor: move;
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
#chat-handle {
|
||||
left: var(--chat-left); top: var(--chat-top);
|
||||
width: var(--chat-width); height: var(--chat-height);
|
||||
}
|
||||
#alert-handle {
|
||||
left: calc(var(--alert-left) - 80px); top: calc(var(--alert-top) - 20px);
|
||||
width: 160px; height: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat">
|
||||
<div class="chat-msg"><span class="username" style="color:#9146ff">bashynx</span>: This is a preview message</div>
|
||||
<div class="chat-msg"><span class="username" style="color:#ff6b6b">someviewer</span>: Looking great!</div>
|
||||
<div class="chat-msg"><span class="username" style="color:#6bcbff">anotheruser</span>: pog pog pog</div>
|
||||
</div>
|
||||
<div id="alert-box">Alert preview</div>
|
||||
<div id="chat-handle" data-widget="chat"></div>
|
||||
<div id="alert-handle" data-widget="alert"></div>
|
||||
</body>
|
||||
</html>
|
||||
79
views/settings.ejs
Normal file
79
views/settings.ejs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<h2>Settings</h2>
|
||||
|
||||
<article>
|
||||
<header><strong>Overlay URL</strong></header>
|
||||
<p>Add this as a <strong>Browser Source</strong> in OBS (1920×1080, transparent background):</p>
|
||||
<code><%= baseUrl %>/overlay/<%= settings.overlay_id %></code>
|
||||
</article>
|
||||
|
||||
<form method="POST" action="/settings">
|
||||
|
||||
<h3>Chat</h3>
|
||||
<label>
|
||||
Twitch channel
|
||||
<input type="text" name="channel" value="<%= settings.channel %>" placeholder="yourchannel">
|
||||
</label>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||
<label>
|
||||
Message timeout (seconds)
|
||||
<input type="number" name="message_timeout" value="<%= settings.message_timeout %>" min="0" max="600">
|
||||
<small>0 = messages stay forever</small>
|
||||
</label>
|
||||
<label>
|
||||
Max messages shown
|
||||
<input type="number" name="max_messages" value="<%= settings.max_messages %>" min="1" max="100">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3>Gap audio</h3>
|
||||
<p><small>Plays a sound when a new message arrives after a period of silence.</small></p>
|
||||
<label>
|
||||
<input type="checkbox" name="gap_audio_enabled" role="switch" <%= settings.gap_audio_enabled ? 'checked' : '' %>>
|
||||
Enable gap audio
|
||||
</label>
|
||||
<label>
|
||||
Sound URL
|
||||
<input type="text" name="gap_audio_url" value="<%= settings.gap_audio_url %>" placeholder="https://... or /media/filename.mp3">
|
||||
</label>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:1rem;">
|
||||
<label>
|
||||
Min silence before triggering (s)
|
||||
<input type="number" name="gap_audio_min_silence" value="<%= settings.gap_audio_min_silence %>" min="0">
|
||||
<small>Sound won't play if chat was more recent than this</small>
|
||||
</label>
|
||||
<label>
|
||||
Min volume (0–1)
|
||||
<input type="number" name="gap_audio_min_volume" value="<%= settings.gap_audio_min_volume %>" min="0" max="1" step="0.05">
|
||||
<small>Volume right after min silence</small>
|
||||
</label>
|
||||
<label>
|
||||
Max volume (0–1)
|
||||
<input type="number" name="gap_audio_max_volume" value="<%= settings.gap_audio_max_volume %>" min="0" max="1" step="0.05">
|
||||
<small>Volume after long silence</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Full volume after silence of (seconds)
|
||||
<input type="number" name="gap_audio_loud_after" value="<%= settings.gap_audio_loud_after %>" min="0">
|
||||
<small>e.g. 3600 = 1 hour of silence → max volume</small>
|
||||
</label>
|
||||
|
||||
<h3>Custom CSS / JS</h3>
|
||||
<label>
|
||||
Custom CSS (injected into the overlay)
|
||||
<textarea name="custom_css" rows="6" style="font-family:monospace"><%= settings.custom_css %></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Custom JS (injected into the overlay, runs after init)
|
||||
<textarea name="custom_js" rows="6" style="font-family:monospace"><%= settings.custom_js %></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save settings</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<h3>EventSub</h3>
|
||||
<form method="POST" action="/settings/reconnect" style="display:inline;">
|
||||
<button type="submit" class="outline">Reconnect EventSub</button>
|
||||
</form>
|
||||
<small style="margin-left:1rem;">Use this if Twitch alerts stop working after a token refresh.</small>
|
||||
Loading…
Add table
Add a link
Reference in a new issue