All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s
Streamer dashboard gains an editable AI assistant name (default "Альтушка Ирина"), stored per-streamer and used for the overlay label, TTS intro, and DeepSeek system prompt. Also fixes a bug where a long message's TTS audio could still be playing when the next queued message started its own audio — the overlay queue now waits for the current audio to actually finish (not just the configured display duration) before advancing.
128 lines
3.8 KiB
JavaScript
128 lines
3.8 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 when that timer
|
|
// fires (e.g. a long question read aloud), we must not start the next
|
|
// message's audio on top of it — so we also wait for the audio to
|
|
// actually finish before advancing.
|
|
let minTimeElapsed = false;
|
|
let audioFinished = !current.audio;
|
|
|
|
const maybeAdvance = () => {
|
|
if (!minTimeElapsed || !audioFinished) return;
|
|
box.classList.remove('visible');
|
|
current = null;
|
|
setTimeout(advanceQueue, FADE_MS);
|
|
};
|
|
|
|
current.startAudio = (audio) => {
|
|
audioFinished = false;
|
|
playSpeech(audio.data, audio.mimeType, () => {
|
|
audioFinished = true;
|
|
maybeAdvance();
|
|
});
|
|
};
|
|
|
|
if (current.audio) {
|
|
current.startAudio(current.audio);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
minTimeElapsed = true;
|
|
maybeAdvance();
|
|
}, 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) {
|
|
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.');
|
|
});
|