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:
commit
7882db1f37
12 changed files with 1930 additions and 0 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.env
|
||||
.git
|
||||
15
.env.example
Normal file
15
.env.example
Normal 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
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
.env
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
10
docker-compose.yml
Normal 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
1417
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal 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
55
public/overlay.html
Normal 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
72
public/overlay.js
Normal 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
77
public/sender.html
Normal 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
86
public/sender.js
Normal 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
159
server.js
Normal 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}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue