Initial commit: overlay + sender pages for StreamLabs browser source

Express + Socket.IO backend with Google login on the sender page,
a transparent overlay page for OBS/StreamLabs Browser Source, and
TTS voice-over via the edge-tts Python CLI (same engine as
t2sTelegramBot). Dockerized for deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-01 15:17:25 +00:00
commit 7882db1f37
12 changed files with 1930 additions and 0 deletions

55
public/overlay.html Normal file
View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Overlay</title>
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
width: 100%;
height: 100%;
overflow: hidden;
}
#box {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.95);
max-width: 80%;
padding: 20px 32px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-family: system-ui, sans-serif;
font-size: 36px;
font-weight: 600;
text-align: center;
border-radius: 16px;
opacity: 0;
transition: opacity 0.4s ease, transform 0.4s ease;
word-wrap: break-word;
}
#box.visible {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
#from {
font-size: 18px;
font-weight: 400;
opacity: 0.75;
margin-bottom: 6px;
}
</style>
</head>
<body>
<div id="box">
<div id="from"></div>
<div id="text"></div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/overlay.js"></script>
</body>
</html>

72
public/overlay.js Normal file
View file

@ -0,0 +1,72 @@
const box = document.getElementById('box');
const fromEl = document.getElementById('from');
const textEl = document.getElementById('text');
const socket = io();
let hideTimer = null;
let audioCtx = null;
function getAudioCtx() {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
// Browsers suspend AudioContext until a user gesture happens on the page.
// OBS's Browser Source doesn't enforce this, but for testing in a normal
// tab, unlock it on the first click/keypress anywhere on the page.
['click', 'keydown'].forEach((eventName) => {
document.addEventListener(eventName, () => {
const ctx = getAudioCtx();
if (ctx.state === 'suspended') ctx.resume();
}, { once: true });
});
function playNotificationSound() {
try {
const ctx = getAudioCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.setValueAtTime(1320, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.35);
} catch (err) {
console.error('Could not play notification sound:', err);
}
}
function showMessage(text, from) {
clearTimeout(hideTimer);
fromEl.textContent = from || '';
fromEl.style.display = from ? 'block' : 'none';
textEl.textContent = text;
// force reflow so the transition re-triggers even if a message is already visible
void box.offsetWidth;
box.classList.add('visible');
playNotificationSound();
const durationMs = 10000;
hideTimer = setTimeout(() => {
box.classList.remove('visible');
}, durationMs);
}
function playSpeech(base64Audio, mimeType) {
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
}
socket.on('display_message', (payload) => {
showMessage(payload.text, payload.from);
});
socket.on('display_audio', (payload) => {
playSpeech(payload.audio, payload.mimeType);
});

77
public/sender.html Normal file
View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Отправить сообщение стримеру</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 480px;
margin: 60px auto;
padding: 0 20px;
}
#user-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
#user-bar img {
width: 32px;
height: 32px;
border-radius: 50%;
}
#logout {
margin-left: auto;
cursor: pointer;
color: #888;
font-size: 14px;
}
textarea {
width: 100%;
height: 100px;
font-size: 16px;
padding: 10px;
box-sizing: border-box;
resize: vertical;
}
button {
margin-top: 10px;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
#status {
margin-top: 10px;
font-size: 14px;
color: #888;
min-height: 20px;
}
#hidden-until-login {
display: none;
}
</style>
</head>
<body>
<div id="login-container"></div>
<div id="hidden-until-login">
<div id="user-bar">
<img id="user-pic" src="" alt="" />
<span id="user-name"></span>
<span id="logout">выйти</span>
</div>
<textarea id="message" placeholder="Текст для оверлея..." maxlength="500"></textarea>
<br />
<button id="send">Отправить</button>
<div id="status"></div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/sender.js"></script>
</body>
</html>

86
public/sender.js Normal file
View file

@ -0,0 +1,86 @@
const loginContainer = document.getElementById('login-container');
const mainSection = document.getElementById('hidden-until-login');
const userPic = document.getElementById('user-pic');
const userName = document.getElementById('user-name');
const logoutBtn = document.getElementById('logout');
const messageInput = document.getElementById('message');
const sendBtn = document.getElementById('send');
const statusEl = document.getElementById('status');
const socket = io();
function showLoggedIn(user) {
loginContainer.style.display = 'none';
mainSection.style.display = 'block';
userPic.src = user.picture || '';
userName.textContent = user.name || user.email;
}
function showLoggedOut() {
loginContainer.style.display = 'block';
mainSection.style.display = 'none';
}
async function handleCredentialResponse(response) {
const res = await fetch('/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.credential }),
});
if (res.ok) {
const { user } = await res.json();
showLoggedIn(user);
// The socket connected before login with no session cookie; reconnect so
// its handshake picks up the freshly-issued, now-authenticated cookie.
socket.disconnect();
socket.connect();
} else {
statusEl.textContent = 'Не удалось войти, попробуйте ещё раз';
}
}
async function init() {
const configRes = await fetch('/config');
const { googleClientId } = await configRes.json();
google.accounts.id.initialize({
client_id: googleClientId,
callback: handleCredentialResponse,
});
google.accounts.id.renderButton(loginContainer, { theme: 'outline', size: 'large' });
const meRes = await fetch('/me');
const { user } = await meRes.json();
if (user) {
showLoggedIn(user);
} else {
showLoggedOut();
}
}
sendBtn.addEventListener('click', () => {
const text = messageInput.value.trim();
if (!text) return;
socket.emit('send_message', text);
messageInput.value = '';
statusEl.textContent = 'Отправлено';
setTimeout(() => { statusEl.textContent = ''; }, 1500);
});
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
sendBtn.click();
}
});
socket.on('send_error', (msg) => {
statusEl.textContent = 'Ошибка: ' + msg;
});
logoutBtn.addEventListener('click', async () => {
await fetch('/auth/logout', { method: 'POST' });
showLoggedOut();
});
init();