send2streamer/public/overlay.js
vrubelroman b669c2d0b7
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
Add streamer dashboard stats; fix TTS overlap race properly
Dashboard now shows unique visitors (via sender link), total messages,
and unique senders per streamer, tracked via new viewer_visits/messages
tables.

The previous overlay TTS-overlap fix had a race: audio not having
arrived yet was treated the same as "no audio at all", letting the
queue advance before slow-to-synthesize audio (e.g. a long question)
ever started playing. Now "not arrived yet" is explicitly distinct
from "finished playing", with a grace-period fallback for genuine TTS
failures.

Also drop the hardcoded AI name from ai-prompt.txt since it's now
injected dynamically from the streamer's configured ai_name.
2026-07-08 18:04:02 +00:00

141 lines
4.5 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, onEnded) {
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
const done = () => { if (onEnded) onEnded(); };
audio.addEventListener('ended', done);
audio.addEventListener('error', done);
audio.play().catch((err) => {
console.error('Could not play TTS audio:', err);
done();
});
}
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();
// The message stays up for at least durationMs (streamer's configured
// display time), but if its TTS audio is still playing (or hasn't even
// arrived yet — synthesis is async and can take a few seconds, longer
// than a short configured duration) when that timer fires, we must not
// start the next message's audio on top of it. So we only advance once
// BOTH the minimum time has passed AND we know the audio is actually
// done — "not arrived yet" is treated as "still waiting", not "done",
// unlike an earlier version of this code that got that backwards.
let minTimeElapsed = false;
// 'unknown' (no audio yet, may still arrive), 'playing', or 'done'.
let audioState = current.audio ? 'playing' : 'unknown';
const maybeAdvance = () => {
if (!minTimeElapsed || audioState !== 'done') return;
box.classList.remove('visible');
current = null;
setTimeout(advanceQueue, FADE_MS);
};
current.startAudio = (audio) => {
audioState = 'playing';
playSpeech(audio.data, audio.mimeType, () => {
audioState = 'done';
maybeAdvance();
});
};
if (current.audio) {
current.startAudio(current.audio);
}
setTimeout(() => {
minTimeElapsed = true;
maybeAdvance();
}, current.durationMs || 10000);
// Safety net: if TTS synthesis fails server-side, no display_audio ever
// arrives for this id — don't wait forever for audio that's never coming.
setTimeout(() => {
if (audioState === 'unknown') {
audioState = 'done';
maybeAdvance();
}
}, (current.durationMs || 10000) + 8000);
}
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) {
current.startAudio(audio);
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.');
});