rework
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
Bashy 2026-04-26 23:00:55 +03:00
parent c46f2f2fc3
commit 6c7facadce
1831 changed files with 477243 additions and 2607 deletions

88
views/actions.ejs Normal file
View 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
View 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
View 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
View 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 (01) <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 (01) <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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
<%- settings.custom_js %>
</script>
</body>
</html>

87
views/overlay_preview.ejs Normal file
View 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
View 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 (01)
<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 (01)
<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>