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

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
.env
.git

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
PORT=3000
# OAuth 2.0 Client ID (Web application) from https://console.cloud.google.com/apis/credentials
# Authorized JavaScript origins must include the origin you open sender.html from,
# e.g. http://localhost:3000 for local dev.
GOOGLE_CLIENT_ID=
# Random long string, used to sign the session cookie. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=
# Microsoft Edge Read Aloud voice used for TTS (free, no API key needed).
# Same engine as t2sTelegramBot uses. List of voices: `npx edge-tts-node --list-voices` (if available)
# or see https://github.com/Migushthe2nd/MsEdgeTTS
TTS_VOICE=ru-RU-DmitryNeural

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
.env

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:22-alpine
RUN apk add --no-cache python3 py3-pip && \
pip install --no-cache-dir --break-system-packages edge-tts
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY server.js ./
COPY public ./public
EXPOSE 3000
CMD ["node", "server.js"]

10
docker-compose.yml Normal file
View file

@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- SESSION_SECRET=${SESSION_SECRET}
restart: unless-stopped

1417
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "send-message-to-streamer",
"version": "1.0.0",
"private": true,
"description": "Overlay page for OBS/StreamLabs browser source + sender page with Google login",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
"google-auth-library": "^9.14.1",
"socket.io": "^4.7.5"
}
}

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();

159
server.js Normal file
View file

@ -0,0 +1,159 @@
require('dotenv').config();
const path = require('path');
const express = require('express');
const session = require('express-session');
const { createServer } = require('http');
const { Server } = require('socket.io');
const { OAuth2Client } = require('google-auth-library');
const { execFile } = require('child_process');
const PORT = process.env.PORT || 3000;
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const SESSION_SECRET = process.env.SESSION_SECRET;
const TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural';
if (!GOOGLE_CLIENT_ID) {
console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)');
process.exit(1);
}
if (!SESSION_SECRET) {
console.error('Missing SESSION_SECRET in .env (see .env.example)');
process.exit(1);
}
const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID);
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
const sessionMiddleware = session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
// set to true once served over HTTPS in production
secure: false,
sameSite: 'lax',
},
});
app.use(sessionMiddleware);
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Share the express session with socket.io connections.
const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
io.use(wrap(sessionMiddleware));
app.get('/config', (req, res) => {
res.json({ googleClientId: GOOGLE_CLIENT_ID });
});
app.get('/me', (req, res) => {
res.json({ user: req.session.user || null });
});
app.post('/auth/google', async (req, res) => {
const { credential } = req.body;
if (!credential) {
return res.status(400).json({ error: 'missing credential' });
}
try {
const ticket = await oauthClient.verifyIdToken({
idToken: credential,
audience: GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
req.session.user = {
email: payload.email,
name: payload.name,
picture: payload.picture,
};
console.log('[auth] logged in:', payload.email);
res.json({ user: req.session.user });
} catch (err) {
console.error('Google token verification failed:', err.message);
res.status(401).json({ error: 'invalid token' });
}
});
// Shells out to the `edge-tts` Python CLI (same engine as t2sTelegramBot uses)
// instead of the edge-tts-node npm package, whose request-signing algorithm
// currently gets rejected by Microsoft's servers with a 403.
function synthesizeSpeech(text) {
return new Promise((resolve, reject) => {
execFile(
'edge-tts',
['-t', text, '-v', TTS_VOICE],
{ encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 },
(err, stdout) => {
if (err) return reject(err);
resolve(stdout);
}
);
});
}
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
io.on('connection', (socket) => {
const connectedUser = socket.request.session.user;
console.log(`[socket] connected id=${socket.id} user=${connectedUser ? connectedUser.email : '(none)'}`);
socket.on('disconnect', (reason) => {
console.log(`[socket] disconnected id=${socket.id} reason=${reason}`);
});
socket.on('send_message', (text) => {
// The session snapshot on socket.request is captured once, at connect time.
// If the socket connected before login (page load opens it immediately),
// it never sees a session updated later by the separate /auth/google request
// unless we explicitly reload it from the store here.
socket.request.session.reload((err) => {
const user = !err && socket.request.session.user;
console.log(`[send_message] id=${socket.id} user=${user ? user.email : '(none)'} text=${JSON.stringify(text)}`);
if (!user) {
socket.emit('send_error', 'Not authenticated');
return;
}
if (typeof text !== 'string' || !text.trim()) {
return;
}
const message = text.trim().slice(0, 500);
const clientCount = io.engine.clientsCount;
console.log(`[broadcast] "${message}" -> ${clientCount} connected client(s)`);
io.emit('display_message', {
text: message,
from: user.name,
at: Date.now(),
});
// Voice is generated after the fact so the text shows up immediately
// instead of waiting on the round trip to the TTS service.
synthesizeSpeech(message)
.then((audioBuffer) => {
io.emit('display_audio', {
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});
})
.catch((err) => {
console.error('[tts] synthesis failed:', err);
});
});
});
});
httpServer.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});