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
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue