All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
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>
99 lines
3 KiB
JavaScript
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.');
|
|
});
|