send2streamer/public/overlay.js
vrubelroman 03a3a4c4af
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
Queue overlay messages instead of interrupting the current one
Messages now play one at a time for their full configured duration
before the next one appears, instead of a new message immediately
replacing whatever was on screen. TTS audio is tagged with the same
message id as its text so it stays in sync even though it's
generated and delivered asynchronously after the text broadcast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 17:05:01 +00:00

99 lines
3 KiB
JavaScript

const box = document.getElementById('box');
const fromEl = document.getElementById('from');
const textEl = document.getElementById('text');
const token = location.pathname.split('/').filter(Boolean).pop();
const socket = io({ query: { role: 'overlay', token } });
const FADE_MS = 400; // matches the CSS transition duration on #box
const queue = [];
let current = null; // { id, text, from, durationMs, audio }
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 playSpeech(base64Audio, mimeType) {
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
}
function advanceQueue() {
if (current || queue.length === 0) return;
current = queue.shift();
fromEl.textContent = current.from || '';
fromEl.style.display = current.from ? 'block' : 'none';
textEl.textContent = current.text;
// force reflow so the transition re-triggers even if a message was already visible
void box.offsetWidth;
box.classList.add('visible');
playNotificationSound();
if (current.audio) {
playSpeech(current.audio.data, current.audio.mimeType);
}
setTimeout(() => {
box.classList.remove('visible');
current = null;
setTimeout(advanceQueue, FADE_MS);
}, current.durationMs || 10000);
}
socket.on('display_message', (payload) => {
queue.push({
id: payload.id,
text: payload.text,
from: payload.from,
durationMs: payload.durationMs,
audio: null,
});
advanceQueue();
});
socket.on('display_audio', (payload) => {
const audio = { data: payload.audio, mimeType: payload.mimeType };
if (current && current.id === payload.id) {
playSpeech(audio.data, audio.mimeType);
return;
}
const queued = queue.find((item) => item.id === payload.id);
if (queued) queued.audio = audio;
});
socket.on('invalid_link', () => {
console.error('Invalid overlay link — check the URL from your dashboard.');
});